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:
Ilia Denisov
2026-07-03 14:53:51 +02:00
parent f1a12c2f44
commit 8d2cd97e17
10 changed files with 287 additions and 11 deletions
+31 -4
View File
@@ -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)
+23 -1
View File
@@ -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