From 4ef7486eef285d2d47544a7648176b6bfb1a95a5 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 19:40:21 +0100 Subject: [PATCH] notification: smtp channel --- internal/notification/smtp.go | 140 ++++++++++++++++++++++++++ internal/notification/smtp_test.go | 154 +++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 internal/notification/smtp.go create mode 100644 internal/notification/smtp_test.go diff --git a/internal/notification/smtp.go b/internal/notification/smtp.go new file mode 100644 index 0000000..296bfdf --- /dev/null +++ b/internal/notification/smtp.go @@ -0,0 +1,140 @@ +package notification + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" + "time" +) + +// SMTPConfig holds the configuration for an SMTP notification channel. +type SMTPConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Encryption string `json:"encryption"` // "starttls" | "tls" | "none" + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` + To string `json:"to"` +} + +// SMTPChannel delivers alert notifications via plain-text email. +type SMTPChannel struct { + cfg SMTPConfig + // messageIDDomain holds the public base hostname of restic-manager so + // Message-IDs include a stable right-hand-side. Falls back to + // "restic-manager.local" when unset. + messageIDDomain string +} + +// NewSMTPChannel builds an SMTP channel. messageIDDomain comes from +// cfg.Cfg.BaseURL — caller passes it through. +func NewSMTPChannel(cfg SMTPConfig, messageIDDomain string) *SMTPChannel { + if messageIDDomain == "" { + messageIDDomain = "restic-manager.local" + } + return &SMTPChannel{cfg: cfg, messageIDDomain: messageIDDomain} +} + +// Kind returns "smtp". +func (c *SMTPChannel) Kind() string { return "smtp" } + +// Send delivers the payload as a plain-text email via SMTP. +// Returns (250, latency, nil) on success. +func (c *SMTPChannel) Send(ctx context.Context, p Payload) (int, time.Duration, error) { + t0 := time.Now() + addr := fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port) + + // Dial respects ctx (we use net.Dialer). + dialer := &net.Dialer{Timeout: 10 * time.Second} + rawConn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return 0, time.Since(t0), fmt.Errorf("smtp: dial %s: %w", addr, err) + } + + var client *smtp.Client + switch strings.ToLower(c.cfg.Encryption) { + case "tls": + conn := tls.Client(rawConn, &tls.Config{ServerName: c.cfg.Host, MinVersion: tls.VersionTLS12}) + client, err = smtp.NewClient(conn, c.cfg.Host) + case "starttls", "": + client, err = smtp.NewClient(rawConn, c.cfg.Host) + if err == nil { + err = client.StartTLS(&tls.Config{ServerName: c.cfg.Host, MinVersion: tls.VersionTLS12}) + } + case "none": + client, err = smtp.NewClient(rawConn, c.cfg.Host) + default: + _ = rawConn.Close() + return 0, time.Since(t0), fmt.Errorf("smtp: unknown encryption %q", c.cfg.Encryption) + } + if err != nil { + _ = rawConn.Close() + return 0, time.Since(t0), fmt.Errorf("smtp: handshake: %w", err) + } + defer func() { _ = client.Quit() }() + + if c.cfg.Username != "" { + auth := smtp.PlainAuth("", c.cfg.Username, c.cfg.Password, c.cfg.Host) + if err := client.Auth(auth); err != nil { + return 0, time.Since(t0), fmt.Errorf("smtp: auth: %w", err) + } + } + + if err := client.Mail(extractAddr(c.cfg.From)); err != nil { + return 0, time.Since(t0), fmt.Errorf("smtp: MAIL FROM: %w", err) + } + if err := client.Rcpt(c.cfg.To); err != nil { + return 0, time.Since(t0), fmt.Errorf("smtp: RCPT TO: %w", err) + } + wc, err := client.Data() + if err != nil { + return 0, time.Since(t0), fmt.Errorf("smtp: DATA: %w", err) + } + msg := buildEmailBody(c.cfg, c.messageIDDomain, p) + if _, err := wc.Write(msg); err != nil { + return 0, time.Since(t0), fmt.Errorf("smtp: write: %w", err) + } + if err := wc.Close(); err != nil { + return 0, time.Since(t0), fmt.Errorf("smtp: close DATA: %w", err) + } + + return 250, time.Since(t0), nil +} + +// extractAddr pulls the bare email out of a "Name " form. +func extractAddr(s string) string { + if i, j := strings.LastIndex(s, "<"), strings.LastIndex(s, ">"); i >= 0 && j > i { + return s[i+1 : j] + } + return s +} + +// buildEmailBody assembles the RFC 5322 message bytes per the spec. +// Plain text only; subject hardcoded. +func buildEmailBody(cfg SMTPConfig, msgIDDomain string, p Payload) []byte { + var b strings.Builder + b.WriteString("From: " + cfg.From + "\r\n") + b.WriteString("To: " + cfg.To + "\r\n") + b.WriteString(fmt.Sprintf("Subject: [restic-manager] [%s] %s: %s\r\n", p.Severity, p.HostName, p.Kind)) + b.WriteString("Date: " + p.RaisedAt.UTC().Format(time.RFC1123Z) + "\r\n") + b.WriteString("Message-ID: <" + p.AlertID + "@" + msgIDDomain + ">\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("\r\n") + b.WriteString(p.Message + "\r\n\r\n") + b.WriteString("—\r\n") + b.WriteString("Raised at: " + p.RaisedAt.UTC().Format(time.RFC3339) + "\r\n") + b.WriteString("Severity: " + p.Severity + "\r\n") + b.WriteString("Host: " + p.HostName + "\r\n") + b.WriteString("Kind: " + p.Kind + "\r\n") + if p.Link != "" { + b.WriteString("\r\nOpen in restic-manager:\r\n") + b.WriteString(p.Link + "\r\n") + } + b.WriteString("\r\n(This message was sent by restic-manager. Acknowledge or resolve in the UI.)\r\n") + return []byte(b.String()) +} diff --git a/internal/notification/smtp_test.go b/internal/notification/smtp_test.go new file mode 100644 index 0000000..b3d3e06 --- /dev/null +++ b/internal/notification/smtp_test.go @@ -0,0 +1,154 @@ +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 +}