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()) }