feat(adminalert): operator email on new feedback / word complaints
New adminalert worker polls for feedback + word complaints arriving since the last check and coalesces a burst into one digest email per interval (5 min), inert unless a distinct admin sender (BACKEND_SMTP_ADMIN_FROM) and recipient (BACKEND_ADMIN_EMAIL, comma-separated allowed) are configured. The mailer gains a per-message From override and splits a comma-separated To into separate recipients (go-mail needs them as a list). Feedback/game stores gain CountSince/CountComplaintsSince. Unit tests cover the digest, the skip-when- empty, and the recipient split.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user