// 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() }