57d2286f5e
Phase 28's in-game mail UI threads sent messages by the recipient race name, so the bulk `/sent` endpoint now returns the same `UserMailMessageDetail` shape as `/inbox` — single sends contribute one row per message, broadcasts contribute one row per addressee and the UI collapses them by `message_id` into a stand-alone item. - `Store.ListSent` / `Service.ListSent` switched from `[]Message` to `[]InboxEntry`. SQL grows an INNER JOIN with `diplomail_recipients`. - Handler emits `userMailMessageDetailWire` items; the deprecated `userMailSentSummaryWire` is removed. - `openapi.yaml`: `UserMailSentList.items` now reference `UserMailMessageDetail`; the standalone `UserMailSentSummary` schema is dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
442 lines
16 KiB
Go
442 lines
16 KiB
Go
package diplomail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// previewMaxRunes bounds the body excerpt embedded in the push event
|
|
// so the gRPC payload stays small. The value matches the UI's
|
|
// "two lines" tease and is intentionally not configurable — clients
|
|
// drive their own truncation off the canonical fetch.
|
|
const previewMaxRunes = 120
|
|
|
|
// SendPersonal persists a single-recipient personal message and
|
|
// fan-outs a `diplomail.message.received` push event to the
|
|
// recipient. Validation rules:
|
|
//
|
|
// - both sender and recipient must be active members of GameID;
|
|
// - the recipient must differ from the sender;
|
|
// - the body must be non-empty, valid UTF-8, and within the
|
|
// configured byte limit;
|
|
// - the subject must be valid UTF-8 and within the configured
|
|
// byte limit (zero is allowed).
|
|
//
|
|
// On any rule violation the function returns ErrInvalidInput or
|
|
// ErrForbidden; the inserted Message is never persisted in those
|
|
// cases.
|
|
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
|
|
subject := strings.TrimRight(in.Subject, " \t")
|
|
body := strings.TrimRight(in.Body, " \t\n")
|
|
if err := s.validateContent(subject, body); err != nil {
|
|
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)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return Message{}, Recipient{}, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
|
|
}
|
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
|
|
}
|
|
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, recipientID)
|
|
if err != nil {
|
|
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("diplomail: load recipient membership: %w", err)
|
|
}
|
|
|
|
username := sender.UserName
|
|
senderRace := sender.RaceName
|
|
senderUserID := in.SenderUserID
|
|
msgInsert := MessageInsert{
|
|
MessageID: uuid.New(),
|
|
GameID: in.GameID,
|
|
GameName: sender.GameName,
|
|
Kind: KindPersonal,
|
|
SenderKind: SenderKindPlayer,
|
|
SenderUserID: &senderUserID,
|
|
SenderUsername: &username,
|
|
SenderRaceName: &senderRace,
|
|
SenderIP: in.SenderIP,
|
|
Subject: subject,
|
|
Body: body,
|
|
BodyLang: s.deps.Detector.Detect(body),
|
|
BroadcastScope: BroadcastScopeSingle,
|
|
}
|
|
raceName := recipient.RaceName
|
|
rcptInsert := buildRecipientInsert(
|
|
msgInsert.MessageID,
|
|
MemberSnapshot{
|
|
UserID: recipientID,
|
|
GameID: in.GameID,
|
|
GameName: recipient.GameName,
|
|
UserName: recipient.UserName,
|
|
RaceName: raceName,
|
|
PreferredLanguage: recipient.PreferredLanguage,
|
|
Status: "active",
|
|
},
|
|
msgInsert.BodyLang,
|
|
s.nowUTC(),
|
|
)
|
|
|
|
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
|
if err != nil {
|
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: %w", err)
|
|
}
|
|
if len(recipients) != 1 {
|
|
return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: unexpected recipient count %d", len(recipients))
|
|
}
|
|
|
|
if recipients[0].AvailableAt != nil {
|
|
s.publishMessageReceived(ctx, msg, recipients[0])
|
|
}
|
|
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
|
|
// 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 is not leaked. The same sentinel is returned when
|
|
// the caller is no longer an active member of the game and the
|
|
// message is personal-kind: post-kick visibility is restricted to
|
|
// admin/system mail (item 8 of the spec).
|
|
//
|
|
// When `targetLang` is non-empty and differs from the message's
|
|
// `body_lang`, the function consults the translation cache; on a
|
|
// miss it asks the configured Translator to produce a rendering and
|
|
// persists the result. The noop translator returns the input
|
|
// unchanged with `engine == "noop"`, which is treated as
|
|
// "translation unavailable" — the entry comes back with `Translation
|
|
// == nil` and the caller renders the original body.
|
|
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) {
|
|
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
|
if err != nil {
|
|
return InboxEntry{}, err
|
|
}
|
|
allowed, err := s.allowedKinds(ctx, entry.GameID, userID)
|
|
if err != nil {
|
|
return InboxEntry{}, err
|
|
}
|
|
if !allowed[entry.Kind] {
|
|
return InboxEntry{}, ErrNotFound
|
|
}
|
|
if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil {
|
|
entry.Translation = tr
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
// resolveTranslation returns the cached translation for
|
|
// (message, targetLang), lazily computing and persisting one on
|
|
// cache miss. Returns nil when no translation is needed (target is
|
|
// empty, matches `body_lang`, or the message body is itself
|
|
// undetermined) or when the configured translator declares the
|
|
// rendering unavailable.
|
|
func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
|
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
|
return nil
|
|
}
|
|
if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil {
|
|
t := existing
|
|
return &t
|
|
} else if !errors.Is(err, ErrNotFound) {
|
|
s.deps.Logger.Warn("load translation failed",
|
|
zap.String("message_id", msg.MessageID.String()),
|
|
zap.String("target_lang", targetLang),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
if s.deps.Translator == nil {
|
|
return nil
|
|
}
|
|
result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body)
|
|
if err != nil {
|
|
s.deps.Logger.Warn("translator call failed",
|
|
zap.String("message_id", msg.MessageID.String()),
|
|
zap.String("target_lang", targetLang),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
if result.Engine == "" || result.Engine == "noop" {
|
|
return nil
|
|
}
|
|
tr := Translation{
|
|
TranslationID: uuid.New(),
|
|
MessageID: msg.MessageID,
|
|
TargetLang: targetLang,
|
|
TranslatedSubject: result.Subject,
|
|
TranslatedBody: result.Body,
|
|
Translator: result.Engine,
|
|
}
|
|
stored, err := s.deps.Store.InsertTranslation(ctx, tr)
|
|
if err != nil {
|
|
s.deps.Logger.Warn("insert translation failed",
|
|
zap.String("message_id", msg.MessageID.String()),
|
|
zap.String("target_lang", targetLang),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
return &stored
|
|
}
|
|
|
|
// ListInbox returns every non-deleted message addressed to userID in
|
|
// gameID, newest first. Read state is preserved per entry; the HTTP
|
|
// layer renders both the message and the recipient row. Personal
|
|
// messages are filtered out when the caller is no longer an active
|
|
// member of the game so a kicked player keeps read access to the
|
|
// admin/system explanation of the kick but not to historical
|
|
// player-to-player threads.
|
|
//
|
|
// When `targetLang` is non-empty and differs from a row's body
|
|
// language, the function consults the translation cache (without
|
|
// re-translating on miss; the per-message read endpoint owns that
|
|
// path so the bulk listing never blocks on translator I/O).
|
|
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) {
|
|
entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allowed, err := s.allowedKinds(ctx, gameID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := entries
|
|
if !(allowed[KindPersonal] && allowed[KindAdmin]) {
|
|
out = make([]InboxEntry, 0, len(entries))
|
|
for _, e := range entries {
|
|
if allowed[e.Kind] {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
}
|
|
if targetLang == "" {
|
|
return out, nil
|
|
}
|
|
for i := range out {
|
|
out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// lookupCachedTranslation reads an existing translation row without
|
|
// asking the Translator to compute one. The bulk inbox listing uses
|
|
// this to avoid per-row translator I/O; GetMessage uses the full
|
|
// `resolveTranslation` helper which falls through to the translator
|
|
// on cache miss.
|
|
func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
|
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
|
return nil
|
|
}
|
|
existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang)
|
|
if err != nil {
|
|
if !errors.Is(err, ErrNotFound) {
|
|
s.deps.Logger.Debug("inbox translation lookup failed",
|
|
zap.String("message_id", msg.MessageID.String()),
|
|
zap.Error(err))
|
|
}
|
|
return nil
|
|
}
|
|
out := existing
|
|
return &out
|
|
}
|
|
|
|
// allowedKinds resolves the set of message kinds the caller may read
|
|
// in gameID. An active member can read everything; a former member
|
|
// (status removed or blocked) can read admin-kind only. A user who
|
|
// has never been a member of the game but is still listed as a
|
|
// recipient (legacy / system message) is granted the same admin-only
|
|
// view. The function never returns an empty set: even non-members
|
|
// keep their read access to admin mail.
|
|
func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) {
|
|
if s.deps.Memberships == nil {
|
|
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
|
}
|
|
if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil {
|
|
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
|
} else if !errors.Is(err, ErrNotFound) {
|
|
return nil, err
|
|
}
|
|
return map[string]bool{KindAdmin: true}, nil
|
|
}
|
|
|
|
// ListSent returns the sender-side view of personal messages
|
|
// authored by senderUserID in gameID, newest first. Each entry pairs
|
|
// the message with one of its recipient rows; single sends contribute
|
|
// one entry per message, broadcasts contribute one entry per
|
|
// addressee. Admin and system rows have no `sender_user_id` and are
|
|
// therefore excluded; the user surface does not need them.
|
|
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
|
|
return s.deps.Store.ListSent(ctx, gameID, senderUserID)
|
|
}
|
|
|
|
// MarkRead transitions a recipient row to `read`. Idempotent: a
|
|
// second call on an already-read row is a no-op. Returns the
|
|
// resulting Recipient. ErrNotFound is surfaced when the caller is
|
|
// not a recipient of the message.
|
|
func (s *Service) MarkRead(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
|
|
return s.deps.Store.MarkRead(ctx, messageID, userID, s.nowUTC())
|
|
}
|
|
|
|
// DeleteMessage soft-deletes the recipient row identified by
|
|
// (messageID, userID). The row must already have `read_at` set, or
|
|
// the call returns ErrConflict (item 10 of the spec: open-then-delete).
|
|
// Returns ErrNotFound when the caller is not a recipient.
|
|
func (s *Service) DeleteMessage(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
|
|
return s.deps.Store.SoftDelete(ctx, messageID, userID, s.nowUTC())
|
|
}
|
|
|
|
// UnreadCountsForUser returns the lobby badge breakdown.
|
|
func (s *Service) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) {
|
|
return s.deps.Store.UnreadCountsForUser(ctx, userID)
|
|
}
|
|
|
|
// validateContent enforces the body/subject byte limits and rejects
|
|
// non-UTF-8 input. Stage A applies the rules to plain text only; HTML
|
|
// is treated as plain text by the server (the UI renders via
|
|
// textContent) and gets no special handling.
|
|
func (s *Service) validateContent(subject, body string) error {
|
|
if body == "" {
|
|
return fmt.Errorf("%w: body must not be empty", ErrInvalidInput)
|
|
}
|
|
if !utf8.ValidString(body) {
|
|
return fmt.Errorf("%w: body must be valid UTF-8", ErrInvalidInput)
|
|
}
|
|
if len(body) > s.deps.Config.MaxBodyBytes {
|
|
return fmt.Errorf("%w: body exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxBodyBytes)
|
|
}
|
|
if subject != "" {
|
|
if !utf8.ValidString(subject) {
|
|
return fmt.Errorf("%w: subject must be valid UTF-8", ErrInvalidInput)
|
|
}
|
|
if len(subject) > s.deps.Config.MaxSubjectBytes {
|
|
return fmt.Errorf("%w: subject exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxSubjectBytes)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// publishMessageReceived emits the per-recipient push notification.
|
|
// Failures are logged at debug level: notifications are best-effort
|
|
// over the gRPC stream, and clients always have the unread-counts
|
|
// endpoint as the durable fallback.
|
|
func (s *Service) publishMessageReceived(ctx context.Context, msg Message, recipient Recipient) {
|
|
unreadGame, err := s.deps.Store.UnreadCountForUserGame(ctx, msg.GameID, recipient.UserID)
|
|
if err != nil {
|
|
s.deps.Logger.Warn("compute unread count for push payload failed",
|
|
zap.String("message_id", msg.MessageID.String()),
|
|
zap.String("recipient", recipient.UserID.String()),
|
|
zap.Error(err))
|
|
unreadGame = 0
|
|
}
|
|
unreadTotals, err := s.deps.Store.UnreadCountsForUser(ctx, recipient.UserID)
|
|
if err != nil {
|
|
s.deps.Logger.Warn("compute unread totals for push payload failed",
|
|
zap.String("recipient", recipient.UserID.String()),
|
|
zap.Error(err))
|
|
unreadTotals = nil
|
|
}
|
|
unreadTotal := 0
|
|
for _, u := range unreadTotals {
|
|
unreadTotal += u.Unread
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"message_id": msg.MessageID.String(),
|
|
"game_id": msg.GameID.String(),
|
|
"kind": msg.Kind,
|
|
"sender_kind": msg.SenderKind,
|
|
"subject": msg.Subject,
|
|
"preview": preview(msg.Body, previewMaxRunes),
|
|
"preview_lang": msg.BodyLang,
|
|
"unread_total": unreadTotal,
|
|
"unread_game": unreadGame,
|
|
}
|
|
ev := DiplomailNotification{
|
|
Kind: "diplomail.message.received",
|
|
IdempotencyKey: "diplomail.message.received:" + msg.MessageID.String() + ":" + recipient.UserID.String(),
|
|
Recipient: recipient.UserID,
|
|
Payload: payload,
|
|
}
|
|
if err := s.deps.Notification.PublishDiplomailEvent(ctx, ev); err != nil {
|
|
s.deps.Logger.Warn("publish diplomail event failed",
|
|
zap.String("message_id", msg.MessageID.String()),
|
|
zap.String("recipient", recipient.UserID.String()),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// preview truncates s to at most max runes and appends a horizontal
|
|
// ellipsis when truncation actually happened. The function operates
|
|
// on runes, not bytes, so multibyte UTF-8 sequences (Cyrillic,
|
|
// emoji) survive without corruption.
|
|
func preview(s string, max int) string {
|
|
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
|
return s
|
|
}
|
|
count := 0
|
|
for i := range s {
|
|
if count == max {
|
|
return s[:i] + "…"
|
|
}
|
|
count++
|
|
}
|
|
return s
|
|
}
|