package notify import ( "bytes" "fmt" "net" "net/http" "net/mail" "net/smtp" "strings" "time" "ncore-hnr/internal/model" ) const manualNeededSubject = "nCore HnR manual work" const failureSubject = "nCore HnR check failed" type Sender interface { SendManualNeeded(results []model.ActionResult) error SendFailure(step string, failure error) error } type NotificationNTFY struct { URL string HTTPClient *http.Client } func (n NotificationNTFY) SendManualNeeded(results []model.ActionResult) error { body, ok := manualNeededText(results) if strings.TrimSpace(n.URL) == "" || !ok { return nil } return n.sendMessage(manualNeededSubject, body) } func (n NotificationNTFY) SendFailure(step string, failure error) error { if strings.TrimSpace(n.URL) == "" { return nil } _, body := FailureMessage(step, failure) return n.sendMessage(failureSubject, body) } func (n NotificationNTFY) sendMessage(subject string, body string) error { client := n.HTTPClient if client == nil { client = &http.Client{Timeout: 15 * time.Second} } req, err := http.NewRequest(http.MethodPost, n.URL, bytes.NewBufferString(body)) if err != nil { return err } req.Header.Set("Content-Type", "text/plain; charset=utf-8") req.Header.Set("Title", subject) resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("notify returned %s", resp.Status) } return nil } type NotificationSMTP struct { Host string Port string Username string Password string From string To string } func (s NotificationSMTP) SendManualNeeded(results []model.ActionResult) error { body, ok := manualNeededText(results) if strings.TrimSpace(s.Host) == "" || !ok { return nil } return s.sendMessage(manualNeededSubject, body) } func (s NotificationSMTP) SendFailure(step string, failure error) error { if strings.TrimSpace(s.Host) == "" { return nil } _, body := FailureMessage(step, failure) return s.sendMessage(failureSubject, body) } func (s NotificationSMTP) sendMessage(subject string, body string) error { host := strings.TrimSpace(s.Host) addr := net.JoinHostPort(host, strings.TrimSpace(s.Port)) from, err := mail.ParseAddress(strings.TrimSpace(s.From)) if err != nil { return fmt.Errorf("parse smtp from: %w", err) } recipients, err := mail.ParseAddressList(strings.TrimSpace(s.To)) if err != nil { return fmt.Errorf("parse smtp to: %w", err) } to := make([]string, 0, len(recipients)) for _, recipient := range recipients { to = append(to, recipient.Address) } var auth smtp.Auth if strings.TrimSpace(s.Username) != "" || strings.TrimSpace(s.Password) != "" { auth = smtp.PlainAuth("", strings.TrimSpace(s.Username), strings.TrimSpace(s.Password), host) } message := strings.Builder{} message.WriteString(fmt.Sprintf("From: %s\r\n", from.String())) message.WriteString(fmt.Sprintf("To: %s\r\n", formatAddressList(recipients))) message.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) message.WriteString("MIME-Version: 1.0\r\n") message.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") message.WriteString("\r\n") message.WriteString(body) if err := smtp.SendMail(addr, auth, from.Address, to, []byte(message.String())); err != nil { return fmt.Errorf("send smtp notification: %w", err) } return nil } func ManualNeededMessage(results []model.ActionResult) (string, string, bool) { body, ok := manualNeededText(results) return manualNeededSubject, body, ok } func FailureMessage(step string, failure error) (string, string) { var body strings.Builder body.WriteString("nCore HnR check failed after retries.\n") body.WriteString(fmt.Sprintf("Step: %s\n", strings.TrimSpace(step))) if failure != nil { body.WriteString(fmt.Sprintf("Error: %s\n", failure.Error())) } body.WriteString("Action: review the CronJob logs. Failed nCore/qBittorrent setup steps do not clear unresolved torrent state; the next cron run will retry.\n") return failureSubject, body.String() } func manualNeededText(results []model.ActionResult) (string, bool) { var body strings.Builder manualCount := 0 body.WriteString("nCore HnR torrents need manual work:\n") for _, result := range results { if !result.ManualNeeded { continue } manualCount++ body.WriteString(fmt.Sprintf("- %s", result.Torrent.Name)) if result.QBit != nil { body.WriteString(fmt.Sprintf(" (qBit: %s, %.1f%%)", result.QBit.State, result.QBit.Progress*100)) } body.WriteString("\n") } return body.String(), manualCount > 0 } func formatAddressList(addresses []*mail.Address) string { formatted := make([]string, 0, len(addresses)) for _, address := range addresses { formatted = append(formatted, address.String()) } return strings.Join(formatted, ", ") }