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
+127
View File
@@ -0,0 +1,127 @@
// Package diplomail owns the diplomatic-mail subsystem of the Galaxy
// backend service. Messages live in the lobby-side domain (their
// storage and lifecycle are tied to a game), but they are surfaced
// in-game: lobby exposes only an unread-count badge per game while the
// in-game mail view reads and writes through this package.
//
// Stage A implements the personal single-recipient subset:
//
// - send/read/mark-read/soft-delete handlers for a player addressing
// one other active member of the game;
// - a push event (`diplomail.message.received`) materialised through
// the existing notification pipeline so the recipient gets a live
// toast when online;
// - an unread-counts endpoint that drives the lobby badge.
//
// Later stages add admin/owner/system mail, lifecycle hooks, paid-tier
// player broadcasts, multi-game broadcasts, bulk purge, and the
// language-detection / translation cache.
package diplomail
import (
"time"
"galaxy/backend/internal/config"
"go.uber.org/zap"
)
// Kind values stored verbatim in `diplomail_messages.kind`. The schema
// CHECK constraint pins this to the closed set declared below.
const (
// KindPersonal is a replyable player-to-player message. The
// sender is always a `sender_kind='player'`.
KindPersonal = "personal"
// KindAdmin is a non-replyable administrative notification.
// The sender is either a human admin (`sender_kind='admin'`)
// or the system itself (`sender_kind='system'`).
KindAdmin = "admin"
)
// Sender kind values stored verbatim in `diplomail_messages.sender_kind`.
const (
// SenderKindPlayer marks the sender as an end-user account.
// `sender_user_id` and `sender_username` carry the player's id
// and immutable `accounts.user_name`.
SenderKindPlayer = "player"
// SenderKindAdmin marks the sender as a site administrator.
// `sender_username` carries `admin_accounts.username`.
SenderKindAdmin = "admin"
// SenderKindSystem marks the sender as the service itself
// (lifecycle hooks). Both id and username are NULL.
SenderKindSystem = "system"
)
// Broadcast scope values stored verbatim in
// `diplomail_messages.broadcast_scope`. Stage A only emits `single`;
// Stage B / C add `game_broadcast` and `multi_game_broadcast`.
const (
BroadcastScopeSingle = "single"
BroadcastScopeGameBroadcast = "game_broadcast"
BroadcastScopeMultiGameBroadcast = "multi_game_broadcast"
)
// LangUndetermined is the BCP 47 placeholder stored in
// `diplomail_messages.body_lang` when language detection has not yet
// been performed or could not produce a result. Stage A writes this
// value unconditionally; Stage D replaces it with the detected tag.
const LangUndetermined = "und"
// Service is the diplomatic-mail entry point. Every public method is
// goroutine-safe; concurrency safety is delegated to Postgres for
// persisted state.
type Service struct {
deps Deps
}
// NewService constructs a Service from deps. Logger and Now are
// defaulted; Store must be non-nil and Memberships must be non-nil
// because every send path queries the active membership roster.
func NewService(deps Deps) *Service {
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("diplomail")
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Notification == nil {
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
}
if deps.Config.MaxBodyBytes <= 0 {
deps.Config.MaxBodyBytes = 4096
}
if deps.Config.MaxSubjectBytes < 0 {
deps.Config.MaxSubjectBytes = 256
}
return &Service{deps: deps}
}
// Config returns the service's runtime configuration. Tests and the
// HTTP layer occasionally surface the limits to clients (the OpenAPI
// schema documents them too).
func (s *Service) Config() config.DiplomailConfig {
if s == nil {
return config.DiplomailConfig{}
}
return s.deps.Config
}
// Logger returns the package-named logger. Used by the optional async
// worker and by tests asserting on log output.
func (s *Service) Logger() *zap.Logger {
if s == nil {
return zap.NewNop()
}
return s.deps.Logger
}
// nowUTC returns the configured clock normalised to UTC. Matches the
// convention used everywhere else in `backend` so persisted
// timestamps compare cleanly regardless of host timezone.
func (s *Service) nowUTC() time.Time {
return s.deps.Now().UTC()
}