diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index f0eceeb..c85a229 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -12,6 +12,7 @@ import ( "fmt" "log" "os/signal" + "strings" "syscall" "time" @@ -20,6 +21,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/accountmerge" + "scrabble/backend/internal/adminalert" "scrabble/backend/internal/ads" "scrabble/backend/internal/banview" "scrabble/backend/internal/config" @@ -44,6 +46,10 @@ import ( // telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. const telemetryShutdownTimeout = 5 * time.Second +// adminAlertInterval is how often the operator-alert worker checks for new feedback / +// complaints; a burst within one interval coalesces into a single digest email. +const adminAlertInterval = 5 * time.Minute + func main() { cfg, err := config.Load() if err != nil { @@ -207,6 +213,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts) feedbackSvc.SetNotifier(hub) + // Operator alert emails on new feedback / word complaints, coalesced into one digest + // per interval. Inert unless a distinct admin sender and recipient are configured. + if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" { + consoleURL := "" + if cfg.PublicBaseURL != "" { + consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm" + } + alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger) + go alerts.Run(ctx, adminAlertInterval) + logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval)) + } + // Robot opponent: provision its durable account pool (a hard startup // dependency, like the dictionaries) and start its move driver. The matchmaker // substitutes a pooled robot for a missing human after the wait window. diff --git a/backend/internal/account/mailer.go b/backend/internal/account/mailer.go index 9a6a288..c5a0d64 100644 --- a/backend/internal/account/mailer.go +++ b/backend/internal/account/mailer.go @@ -15,7 +15,11 @@ import ( // required plain-text body and doubles as the multipart/alternative fallback; // HTML, when non-empty, is the preferred body a capable client renders instead. type Message struct { - To string + // To is the recipient address, or several comma-separated (all get the one message). + To string + // From, when non-empty, overrides the configured sender for this message — the admin + // alert path uses a distinct From from the user-facing confirm-code sender. + From string Subject string Text string HTML string @@ -29,6 +33,18 @@ type Mailer interface { Send(ctx context.Context, msg Message) error } +// splitAddrs splits a comma-separated recipient list into trimmed, non-empty addresses. +func splitAddrs(list string) []string { + parts := strings.Split(list, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if a := strings.TrimSpace(p); a != "" { + out = append(out, a) + } + } + return out +} + // SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer // instead, so a deployment without a relay still runs (the code lands in the log). // TLS is always used and no client certificate is required — only the server @@ -44,6 +60,11 @@ type SMTPConfig struct { // port (implicit TLS on 465, STARTTLS otherwise); set it explicitly for a relay on // a non-standard port (e.g. Selectel's 1127 = SSL, 1126 = STARTTLS). TLS string + // AdminFrom / AdminTo drive the operator alert emails (new feedback / word complaints), + // distinct from the user-facing confirm-code sender. AdminTo may be several + // comma-separated addresses. Both empty disables the alert worker. + AdminFrom string + AdminTo string } const ( @@ -110,10 +131,16 @@ func (m SMTPMailer) Send(ctx context.Context, msg Message) error { return fmt.Errorf("account: build mail client: %w", err) } out := mail.NewMsg() - if err := out.From(m.cfg.From); err != nil { - return fmt.Errorf("account: set From %q: %w", m.cfg.From, err) + from := m.cfg.From + if msg.From != "" { + from = msg.From } - if err := out.To(msg.To); err != nil { + if err := out.From(from); err != nil { + return fmt.Errorf("account: set From %q: %w", from, err) + } + // To may carry several comma-separated recipients; go-mail wants them as separate + // arguments (a single joined string parses as one malformed address). + if err := out.To(splitAddrs(msg.To)...); err != nil { return fmt.Errorf("account: set To %q: %w", msg.To, err) } out.Subject(msg.Subject) diff --git a/backend/internal/account/mailer_test.go b/backend/internal/account/mailer_test.go index de204f0..16a6554 100644 --- a/backend/internal/account/mailer_test.go +++ b/backend/internal/account/mailer_test.go @@ -1,6 +1,28 @@ package account -import "testing" +import ( + "slices" + "testing" +) + +// TestSplitAddrs covers the comma-separated recipient parsing used for the admin alert +// To (several operator mailboxes in one message), including trimming and empty entries. +func TestSplitAddrs(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"a@x.ru", []string{"a@x.ru"}}, + {"a@x.ru, b@y.ru", []string{"a@x.ru", "b@y.ru"}}, + {" a@x.ru ,, b@y.ru ,", []string{"a@x.ru", "b@y.ru"}}, + {"", nil}, + } + for _, c := range cases { + if got := splitAddrs(c.in); !slices.Equal(got, c.want) { + t.Errorf("splitAddrs(%q) = %v, want %v", c.in, got, c.want) + } + } +} // TestSMTPTLSMode covers the explicit TLS mode and the port-based fallback, including // the non-standard Selectel ports (1127 = SSL, 1126 = STARTTLS) that the 465 heuristic diff --git a/backend/internal/adminalert/notify.go b/backend/internal/adminalert/notify.go new file mode 100644 index 0000000..5f2a242 --- /dev/null +++ b/backend/internal/adminalert/notify.go @@ -0,0 +1,116 @@ +// Package adminalert emails the operator when new player feedback or word complaints +// arrive, coalescing a burst into a single digest per interval so a flood is one email, +// not N. It is inert unless an admin sender and recipient are configured. The sender is +// distinct from the user-facing confirm-code From, and the recipient may be several +// comma-separated addresses (the mailer splits them). +package adminalert + +import ( + "context" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "scrabble/backend/internal/account" +) + +// FeedbackCounter counts feedback created since a time (satisfied by feedback.Service). +type FeedbackCounter interface { + CountSince(ctx context.Context, since time.Time) (int, error) +} + +// ComplaintCounter counts word complaints filed since a time (satisfied by game.Service). +type ComplaintCounter interface { + CountComplaintsSince(ctx context.Context, since time.Time) (int, error) +} + +// Notifier polls for new feedback and complaints and emails the operator a digest. +type Notifier struct { + mailer account.Mailer + feedback FeedbackCounter + complaints ComplaintCounter + from string + to string + consoleURL string + clock func() time.Time + log *zap.Logger + last time.Time +} + +// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL, +// when non-empty, is the admin-console link included in the email. log may be nil. The +// watermark starts at "now", so only items arriving after start-up are reported. +func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier { + if log == nil { + log = zap.NewNop() + } + return &Notifier{ + mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL, + clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(), + } +} + +// Run polls on each tick until ctx is cancelled. +func (n *Notifier) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + n.tick(ctx) + } + } +} + +// tick counts what arrived since the last watermark and, if anything did, emails one +// digest. The watermark only advances after a successful send (or a quiet tick), so a +// transient send failure is retried on the next tick — the counts simply grow. +func (n *Notifier) tick(ctx context.Context) { + now := n.clock() + fb, err := n.feedback.CountSince(ctx, n.last) + if err != nil { + n.log.Warn("admin alert: count feedback failed", zap.Error(err)) + return + } + cp, err := n.complaints.CountComplaintsSince(ctx, n.last) + if err != nil { + n.log.Warn("admin alert: count complaints failed", zap.Error(err)) + return + } + if fb == 0 && cp == 0 { + n.last = now + return + } + if err := n.mailer.Send(ctx, n.digest(fb, cp)); err != nil { + n.log.Warn("admin alert: send failed", zap.Error(err)) + return + } + n.log.Info("admin alert sent", zap.Int("feedback", fb), zap.Int("complaints", cp)) + n.last = now +} + +// digest builds the operator alert email for fb new feedback and cp new complaints. +func (n *Notifier) digest(fb, cp int) account.Message { + var parts []string + if fb > 0 { + parts = append(parts, fmt.Sprintf("%d new feedback message(s)", fb)) + } + if cp > 0 { + parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp)) + } + summary := strings.Join(parts, ", ") + text := summary + "." + if n.consoleURL != "" { + text += "\n\nOpen the admin console: " + n.consoleURL + } + return account.Message{ + From: n.from, + To: n.to, + Subject: "Erudit — " + summary, + Text: text, + } +} diff --git a/backend/internal/adminalert/notify_test.go b/backend/internal/adminalert/notify_test.go new file mode 100644 index 0000000..d001bb0 --- /dev/null +++ b/backend/internal/adminalert/notify_test.go @@ -0,0 +1,55 @@ +package adminalert + +import ( + "context" + "strings" + "testing" + "time" + + "scrabble/backend/internal/account" +) + +// The fakes ignore the watermark and return a fixed count, which is all the digest logic +// needs. +type fbCounter struct{ n int } + +func (f fbCounter) CountSince(context.Context, time.Time) (int, error) { return f.n, nil } + +type cpCounter struct{ n int } + +func (c cpCounter) CountComplaintsSince(context.Context, time.Time) (int, error) { return c.n, nil } + +type recordingMailer struct{ sent []account.Message } + +func (m *recordingMailer) Send(_ context.Context, msg account.Message) error { + m.sent = append(m.sent, msg) + return nil +} + +func TestNotifierSkipsWhenNothingNew(t *testing.T) { + mailer := &recordingMailer{} + n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil) + n.tick(context.Background()) + if len(mailer.sent) != 0 { + t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent)) + } +} + +func TestNotifierDigestsNewItems(t *testing.T) { + mailer := &recordingMailer{} + n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil) + n.tick(context.Background()) + if len(mailer.sent) != 1 { + t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent)) + } + msg := mailer.sent[0] + if msg.From != "alerts@erudit-game.ru" || msg.To != "op@x.ru, two@x.ru" { + t.Errorf("digest addressing = From %q To %q", msg.From, msg.To) + } + if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") { + t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject) + } + if !strings.Contains(msg.Text, "/_gm") { + t.Errorf("digest body = %q, want the console link", msg.Text) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b201cfd..2826370 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -143,12 +143,14 @@ func Load() (Config, error) { } smtp := account.SMTPConfig{ - Host: os.Getenv("BACKEND_SMTP_HOST"), - Port: envOr("BACKEND_SMTP_PORT", "587"), - Username: os.Getenv("BACKEND_SMTP_USERNAME"), - Password: os.Getenv("BACKEND_SMTP_PASSWORD"), - From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"), - TLS: os.Getenv("BACKEND_SMTP_TLS"), + Host: os.Getenv("BACKEND_SMTP_HOST"), + Port: envOr("BACKEND_SMTP_PORT", "587"), + Username: os.Getenv("BACKEND_SMTP_USERNAME"), + Password: os.Getenv("BACKEND_SMTP_PASSWORD"), + From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"), + TLS: os.Getenv("BACKEND_SMTP_TLS"), + AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"), + AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), } c := Config{ diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go index 25e71f4..6090bb5 100644 --- a/backend/internal/feedback/service.go +++ b/backend/internal/feedback/service.go @@ -195,6 +195,11 @@ func (svc *Service) CountUnread(ctx context.Context) (int, error) { return svc.store.CountUnread(ctx) } +// CountSince counts feedback created after since, for the operator alert worker. +func (svc *Service) CountSince(ctx context.Context, since time.Time) (int, error) { + return svc.store.CountSince(ctx, since) +} + // Attachment returns a message's file name and bytes, reporting false when absent. func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) { return svc.store.Attachment(ctx, id) diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go index 25a5252..06be3c1 100644 --- a/backend/internal/feedback/store.go +++ b/backend/internal/feedback/store.go @@ -386,3 +386,15 @@ func (s *Store) CountUnread(ctx context.Context) (int, error) { } return n, nil } + +// CountSince counts feedback messages created strictly after since — the operator alert +// worker's "new since the last check" signal. +func (s *Store) CountSince(ctx context.Context, since time.Time) (int, error) { + var n int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM backend.feedback_messages WHERE created_at > $1`, since, + ).Scan(&n); err != nil { + return 0, fmt.Errorf("feedback: count since: %w", err) + } + return n, nil +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index bd9081e..162adaf 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1008,6 +1008,12 @@ func (svc *Service) CountComplaints(ctx context.Context, status string) (int, er return svc.store.CountComplaints(ctx, status) } +// CountComplaintsSince counts word complaints filed after since, for the operator alert +// worker. +func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) { + return svc.store.CountComplaintsSince(ctx, since) +} + // ResolveComplaint closes a complaint with an operator disposition (reject / // accept_add / accept_remove) and an optional note. An accepted complaint then // appears in DictionaryChanges until a rebuilt dictionary is loaded and the diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 0e034d8..e99c724 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -1040,6 +1040,19 @@ func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) return int(dest.Count), nil } +// CountComplaintsSince counts word complaints filed strictly after since — the operator +// alert worker's "new since the last check" signal. +func (s *Store) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) { + stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")). + FROM(table.Complaints). + WHERE(table.Complaints.CreatedAt.GT(postgres.TimestampzT(since))) + var dest struct{ Count int64 } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return 0, fmt.Errorf("game: count complaints since: %w", err) + } + return int(dest.Count), nil +} + // ActiveGames returns the turn clocks of every in-progress game; the sweeper // filters them against the per-move deadline and the player's away window. func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {