535e27008f
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>
239 lines
8.7 KiB
Go
239 lines
8.7 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) {
|
|
if in.SenderUserID == in.RecipientUserID {
|
|
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
|
}
|
|
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
|
|
}
|
|
|
|
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, in.RecipientUserID)
|
|
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
|
|
msgInsert := MessageInsert{
|
|
MessageID: uuid.New(),
|
|
GameID: in.GameID,
|
|
GameName: sender.GameName,
|
|
Kind: KindPersonal,
|
|
SenderKind: SenderKindPlayer,
|
|
SenderUserID: &in.SenderUserID,
|
|
SenderUsername: &username,
|
|
SenderIP: in.SenderIP,
|
|
Subject: subject,
|
|
Body: body,
|
|
BodyLang: LangUndetermined,
|
|
BroadcastScope: BroadcastScopeSingle,
|
|
}
|
|
raceName := recipient.RaceName
|
|
var raceNamePtr *string
|
|
if raceName != "" {
|
|
raceNamePtr = &raceName
|
|
}
|
|
rcptInsert := RecipientInsert{
|
|
RecipientID: uuid.New(),
|
|
MessageID: msgInsert.MessageID,
|
|
GameID: in.GameID,
|
|
UserID: in.RecipientUserID,
|
|
RecipientUserName: recipient.UserName,
|
|
RecipientRaceName: raceNamePtr,
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
s.publishMessageReceived(ctx, msg, recipients[0])
|
|
return msg, recipients[0], nil
|
|
}
|
|
|
|
// 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.
|
|
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) {
|
|
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
|
if err != nil {
|
|
return InboxEntry{}, err
|
|
}
|
|
return entry, nil
|
|
}
|
|
|
|
// 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.
|
|
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) {
|
|
return s.deps.Store.ListInbox(ctx, gameID, userID)
|
|
}
|
|
|
|
// ListSent returns personal messages authored by senderUserID in
|
|
// gameID, newest first. Admin/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) ([]Message, 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
|
|
}
|