package notification import ( "context" "net" "strings" "sync" "testing" "time" ) // fakeSMTPServer accepts a single connection, runs the minimal SMTP // dialogue (HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT) and stores // what came across the wire. Plain (no TLS) — we test the protocol // shape, not crypto. type fakeSMTPServer struct { mu sync.Mutex mailFrom string rcptTo string data string authed bool } func startFakeSMTP(t *testing.T) (string, *fakeSMTPServer) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } srv := &fakeSMTPServer{} t.Cleanup(func() { _ = ln.Close() }) go func() { conn, err := ln.Accept() if err != nil { return } defer func() { _ = conn.Close() }() readLine := func() string { buf := make([]byte, 1024) n, err := conn.Read(buf) if err != nil { return "" } return string(buf[:n]) } write := func(s string) { _, _ = conn.Write([]byte(s)) } write("220 fake.smtp ESMTP\r\n") for { line := readLine() if line == "" { return } cmd := strings.ToUpper(strings.TrimSpace(line)) switch { case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): write("250-fake.smtp\r\n250 AUTH PLAIN\r\n") case strings.HasPrefix(cmd, "AUTH "): srv.mu.Lock() srv.authed = true srv.mu.Unlock() write("235 OK\r\n") case strings.HasPrefix(cmd, "MAIL FROM"): srv.mu.Lock() srv.mailFrom = strings.TrimSpace(strings.TrimPrefix(line, "MAIL FROM:")) srv.mu.Unlock() write("250 OK\r\n") case strings.HasPrefix(cmd, "RCPT TO"): srv.mu.Lock() srv.rcptTo = strings.TrimSpace(strings.TrimPrefix(line, "RCPT TO:")) srv.mu.Unlock() write("250 OK\r\n") case cmd == "DATA": write("354 OK\r\n") // read until "\r\n.\r\n" var data strings.Builder for { chunk := readLine() if chunk == "" { break } data.WriteString(chunk) if strings.Contains(data.String(), "\r\n.\r\n") { break } } srv.mu.Lock() srv.data = data.String() srv.mu.Unlock() write("250 OK\r\n") case cmd == "QUIT": write("221 bye\r\n") return default: write("500 unknown\r\n") } } }() return ln.Addr().String(), srv } func TestSMTPSendsExpectedHeaders(t *testing.T) { t.Parallel() addr, srv := startFakeSMTP(t) host, port := splitHostPort(addr) ch := NewSMTPChannel(SMTPConfig{ Host: host, Port: port, Encryption: "none", Username: "u", Password: "p", From: "Restic-Manager ", To: "ops@example.com", }, "rm.example") _, _, err := ch.Send(context.Background(), Payload{ Event: EventRaised, AlertID: "01ABC", Severity: "warning", Kind: "backup_failed", HostName: "alfa-01", Message: "Backup failed: 401", RaisedAt: time.Date(2026, 5, 4, 15, 42, 1, 0, time.UTC), Link: "https://rm.example/alerts/01ABC", }) if err != nil { t.Fatalf("send: %v", err) } srv.mu.Lock() defer srv.mu.Unlock() if !srv.authed { t.Errorf("AUTH never sent") } if !strings.Contains(srv.mailFrom, "alerts@example.com") { t.Errorf("MAIL FROM: %q", srv.mailFrom) } if !strings.Contains(srv.rcptTo, "ops@example.com") { t.Errorf("RCPT TO: %q", srv.rcptTo) } if !strings.Contains(srv.data, "Subject: [restic-manager] [warning] alfa-01: backup_failed") { t.Errorf("subject missing or wrong: %q", srv.data) } if !strings.Contains(srv.data, "Message-ID: <01ABC@rm.example>") { t.Errorf("Message-ID wrong: %q", srv.data) } if !strings.Contains(srv.data, "Backup failed: 401") { t.Errorf("body missing: %q", srv.data) } } func splitHostPort(addr string) (string, int) { host, portStr, _ := net.SplitHostPort(addr) var port int for _, r := range portStr { port = port*10 + int(r-'0') } return host, port }