notification: smtp channel
This commit is contained in:
@@ -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 <addr@host>" 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())
|
||||||
|
}
|
||||||
@@ -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 <alerts@example.com>",
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user