From a1f22af1792f3c5955e3da123986766bb9933d62 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 17:25:22 -0400 Subject: [PATCH] feat(alerts): add ntfy notification provider with TUI support POST to ntfy server/topic with title, priority, and optional basic auth. TUI alert form includes ntfy type with server URL, topic, priority selector (1-5), and credential fields. --- internal/alert/alert.go | 40 +++++++++++++++++++++ internal/tui/tab_alerts.go | 74 +++++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index 03b943d..71b7570 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -7,6 +7,7 @@ import ( "go-upkeep/internal/models" "net/http" "net/smtp" + "strings" "time" ) @@ -38,6 +39,18 @@ func GetProvider(cfg models.AlertConfig) Provider { To: cfg.Settings["to"], From: cfg.Settings["from"], } + case "ntfy": + priority := "3" + if p, ok := cfg.Settings["priority"]; ok && p != "" { + priority = p + } + return &NtfyProvider{ + ServerURL: cfg.Settings["url"], + Topic: cfg.Settings["topic"], + Priority: priority, + Username: cfg.Settings["username"], + Password: cfg.Settings["password"], + } default: return nil } @@ -102,3 +115,30 @@ func (e *EmailProvider) Send(title, message string) error { message + "\r\n") return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg) } + +type NtfyProvider struct { + ServerURL string + Topic string + Priority string + Username string + Password string +} + +func (n *NtfyProvider) Send(title, message string) error { + url := strings.TrimRight(n.ServerURL, "/") + "/" + n.Topic + req, err := http.NewRequest("POST", url, strings.NewReader(message)) + if err != nil { + return err + } + req.Header.Set("Title", title) + req.Header.Set("Priority", n.Priority) + if n.Username != "" && n.Password != "" { + req.SetBasicAuth(n.Username, n.Password) + } + resp, err := alertClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 7cc2cdc..c0ee2ec 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -40,6 +40,11 @@ type alertFormData struct { SMTPPass string EmailFrom string EmailTo string + NtfyURL string + NtfyTopic string + NtfyUser string + NtfyPass string + NtfyPri string } func fmtAlertType(t string) string { @@ -52,6 +57,8 @@ func fmtAlertType(t string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#F0E442")).Render(t) case "email": return lipgloss.NewStyle().Foreground(lipgloss.Color("#73F59F")).Render(t) + case "ntfy": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render(t) default: return t } @@ -61,7 +68,8 @@ func fmtAlertConfig(alert struct { Type string Settings map[string]string }) string { - if alert.Type == "email" { + switch alert.Type { + case "email": host := alert.Settings["host"] to := alert.Settings["to"] if host != "" && to != "" { @@ -71,11 +79,19 @@ func fmtAlertConfig(alert struct { return limitStr(host, 34) } return subtleStyle.Render("—") + case "ntfy": + topic := alert.Settings["topic"] + url := alert.Settings["url"] + if url != "" && topic != "" { + return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) + } + return subtleStyle.Render("—") + default: + if val, ok := alert.Settings["url"]; ok { + return limitStr(val, 34) + } + return subtleStyle.Render("—") } - if val, ok := alert.Settings["url"]; ok { - return limitStr(val, 34) - } - return subtleStyle.Render("—") } func (m Model) viewAlertsTab() string { @@ -133,6 +149,7 @@ func (m Model) viewAlertsTab() string { func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData = &alertFormData{ AlertType: "discord", + NtfyPri: "3", } if m.editID > 0 { @@ -143,13 +160,20 @@ func (m *Model) initAlertHuhForm() tea.Cmd { if url, ok := alert.Settings["url"]; ok { m.alertFormData.WebhookURL = url } - if alert.Type == "email" { + switch alert.Type { + case "email": m.alertFormData.SMTPHost = alert.Settings["host"] m.alertFormData.SMTPPort = alert.Settings["port"] m.alertFormData.SMTPUser = alert.Settings["user"] m.alertFormData.SMTPPass = alert.Settings["pass"] m.alertFormData.EmailFrom = alert.Settings["from"] m.alertFormData.EmailTo = alert.Settings["to"] + case "ntfy": + m.alertFormData.NtfyURL = alert.Settings["url"] + m.alertFormData.NtfyTopic = alert.Settings["topic"] + m.alertFormData.NtfyUser = alert.Settings["username"] + m.alertFormData.NtfyPass = alert.Settings["password"] + m.alertFormData.NtfyPri = alert.Settings["priority"] } break } @@ -173,6 +197,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd { huh.NewOption("Slack", "slack"), huh.NewOption("Webhook", "webhook"), huh.NewOption("Email (SMTP)", "email"), + huh.NewOption("Ntfy", "ntfy"), ).Value(&m.alertFormData.AlertType), ).Title("Alert Config"), huh.NewGroup( @@ -180,7 +205,31 @@ func (m *Model) initAlertHuhForm() tea.Cmd { Placeholder("https://discord.com/api/webhooks/..."). Value(&m.alertFormData.WebhookURL), ).Title("Webhook").WithHideFunc(func() bool { - return m.alertFormData.AlertType == "email" + return m.alertFormData.AlertType == "email" || m.alertFormData.AlertType == "ntfy" + }), + huh.NewGroup( + huh.NewInput().Title("Ntfy Server URL"). + Placeholder("https://ntfy.sh"). + Value(&m.alertFormData.NtfyURL), + huh.NewInput().Title("Topic"). + Placeholder("my-alerts"). + Value(&m.alertFormData.NtfyTopic), + huh.NewSelect[string]().Title("Priority"). + Options( + huh.NewOption("Min (1)", "1"), + huh.NewOption("Low (2)", "2"), + huh.NewOption("Default (3)", "3"), + huh.NewOption("High (4)", "4"), + huh.NewOption("Urgent (5)", "5"), + ).Value(&m.alertFormData.NtfyPri), + huh.NewInput().Title("Username (optional)"). + Placeholder("admin"). + Value(&m.alertFormData.NtfyUser), + huh.NewInput().Title("Password (optional)"). + EchoMode(huh.EchoModePassword). + Value(&m.alertFormData.NtfyPass), + ).Title("Ntfy Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "ntfy" }), huh.NewGroup( huh.NewInput().Title("SMTP Host"). @@ -213,14 +262,21 @@ func (m *Model) submitAlertForm() { d := m.alertFormData settings := make(map[string]string) - if d.AlertType == "email" { + switch d.AlertType { + case "email": settings["host"] = d.SMTPHost settings["port"] = d.SMTPPort settings["user"] = d.SMTPUser settings["pass"] = d.SMTPPass settings["from"] = d.EmailFrom settings["to"] = d.EmailTo - } else { + case "ntfy": + settings["url"] = d.NtfyURL + settings["topic"] = d.NtfyTopic + settings["priority"] = d.NtfyPri + settings["username"] = d.NtfyUser + settings["password"] = d.NtfyPass + default: settings["url"] = d.WebhookURL }