Files
galaxy-game/backend/internal/diplomail/service.go
T
Ilia Denisov 9f7c9099bc
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · Integration / integration (pull_request) Successful in 1m37s
diplomail (Stage E): LibreTranslate client + async translation worker
Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).

Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.

Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:15:28 +02:00

390 lines
14 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: s.deps.Detector.Detect(body),
BroadcastScope: BroadcastScopeSingle,
}
raceName := recipient.RaceName
rcptInsert := buildRecipientInsert(
msgInsert.MessageID,
MemberSnapshot{
UserID: in.RecipientUserID,
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
}
// 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 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
}