Files
scrabble-game/backend/internal/account/mailer.go
T
Ilia Denisov 8d2cd97e17 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.
2026-07-03 14:53:51 +02:00

178 lines
6.0 KiB
Go

package account
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/wneessen/go-mail"
"go.uber.org/zap"
)
// Message is a transactional email to send through a Mailer. Text is the
// 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 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
}
// Mailer delivers a transactional email. It is the seam behind which the email
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
// fixture (see docs/TESTING.md: no real network in tests). The context bounds the
// delivery and is honoured by the SMTP implementation.
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
// certificate is validated against the system roots.
type SMTPConfig struct {
Host string
Port string
Username string
Password string
From string
// TLS selects the transport security: "ssl" for implicit TLS from connect, or
// "starttls" to upgrade a plaintext connection. Empty derives the mode from the
// 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 (
// SMTP transport-security modes for SMTPConfig.TLS.
smtpTLSImplicit = "ssl"
smtpTLSSTARTTLS = "starttls"
// smtpDialTimeout bounds a single relay connect-and-send. The confirm-code send
// is synchronous on the request path, so an unreachable relay must fail fast
// rather than hold the request open.
smtpDialTimeout = 15 * time.Second
)
// tlsMode resolves the transport-security mode for the relay: the explicitly
// configured SMTPConfig.TLS, or — when unset — implicit TLS on the conventional SSL
// port 465 and STARTTLS on any other port.
func (cfg SMTPConfig) tlsMode(port int) string {
switch strings.ToLower(strings.TrimSpace(cfg.TLS)) {
case smtpTLSImplicit, "tls":
return smtpTLSImplicit
case smtpTLSSTARTTLS:
return smtpTLSSTARTTLS
}
if port == 465 {
return smtpTLSImplicit
}
return smtpTLSSTARTTLS
}
// SMTPMailer sends mail through an SMTP relay using go-mail. When a username is
// set it authenticates, auto-discovering the strongest mechanism the relay
// advertises; otherwise it relays unauthenticated.
type SMTPMailer struct {
cfg SMTPConfig
}
// NewSMTPMailer constructs an SMTPMailer for cfg.
func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
return SMTPMailer{cfg: cfg}
}
// Send delivers a UTF-8 message to msg.To via the configured relay. When msg.HTML
// is set the message is multipart/alternative (plain text plus HTML); otherwise
// it is plain text only.
func (m SMTPMailer) Send(ctx context.Context, msg Message) error {
port, err := strconv.Atoi(m.cfg.Port)
if err != nil {
return fmt.Errorf("account: invalid SMTP port %q: %w", m.cfg.Port, err)
}
opts := []mail.Option{mail.WithPort(port), mail.WithTimeout(smtpDialTimeout)}
if m.cfg.tlsMode(port) == smtpTLSImplicit {
opts = append(opts, mail.WithSSL())
} else {
opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory))
}
if m.cfg.Username != "" {
opts = append(opts,
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
mail.WithUsername(m.cfg.Username),
mail.WithPassword(m.cfg.Password),
)
}
client, err := mail.NewClient(m.cfg.Host, opts...)
if err != nil {
return fmt.Errorf("account: build mail client: %w", err)
}
out := mail.NewMsg()
from := m.cfg.From
if msg.From != "" {
from = msg.From
}
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)
out.SetBodyString(mail.TypeTextPlain, msg.Text)
if msg.HTML != "" {
out.AddAlternativeString(mail.TypeTextHTML, msg.HTML)
}
if err := client.DialAndSendWithContext(ctx, out); err != nil {
return fmt.Errorf("account: send mail to %s: %w", msg.To, err)
}
return nil
}
// LogMailer logs the message instead of sending it. It is the default when no
// SMTP relay is configured and is intended for development only: it logs the body,
// which carries the confirm-code, so it must not be used in production.
type LogMailer struct {
log *zap.Logger
}
// NewLogMailer constructs a LogMailer that logs through log.
func NewLogMailer(log *zap.Logger) LogMailer {
return LogMailer{log: log}
}
// Send logs the message at info level and reports success. It logs the plain-text
// body only (which carries the confirm-code); the HTML alternative is omitted.
func (m LogMailer) Send(_ context.Context, msg Message) error {
if m.log != nil {
m.log.Info("email not sent (log mailer)",
zap.String("to", msg.To), zap.String("subject", msg.Subject), zap.String("body", msg.Text))
}
return nil
}