From 43e72b0d5bdbf7bddb9d97fb452a8b1e7c63b22a Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 23:17:42 +0100 Subject: [PATCH] feat(channels): include event verb in ntfy title + smtp subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise / ack / resolve all rendered with the same title and body on ntfy and SMTP, so a recovery looked identical to the original alert. Webhook was already fine because the JSON envelope carries 'event'. ntfy: Title '[raised · warning] dev backup_failed' (was '[warning] …') Tags 'raised,warning,backup_failed' (was 'warning,backup_failed') Body 'Resolved · ' / 'Acknowledged · ' on those events SMTP: Subject '[restic-manager] [raised · warning] dev: backup_failed' Plus: cmd/_fake_alert now accepts the ref as a positional argument (go run ./cmd/_fake_alert steve-001) instead of silently ignoring unknown positional args. Refuses ambiguous '-ref X positional Y'. --- internal/notification/ntfy.go | 26 +++++++++++++++++++++++--- internal/notification/ntfy_test.go | 4 ++-- internal/notification/smtp.go | 13 ++++++++++++- internal/notification/smtp_test.go | 2 +- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/internal/notification/ntfy.go b/internal/notification/ntfy.go index a7692a1..7753f6e 100644 --- a/internal/notification/ntfy.go +++ b/internal/notification/ntfy.go @@ -58,14 +58,34 @@ func (c *NtfyChannel) Send(ctx context.Context, p Payload) (int, time.Duration, server := strings.TrimRight(c.cfg.ServerURL, "/") url := server + "/" + c.cfg.Topic - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(p.Message)) + // Body carries the event verb so the body alone is unambiguous when + // it shows up on a phone lockscreen without the title. + body := p.Message + switch p.Event { + case EventResolved: + body = "Resolved · " + p.Message + case EventAcknowledged: + body = "Acknowledged · " + p.Message + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) if err != nil { return 0, 0, fmt.Errorf("ntfy: build request: %w", err) } + // Title prefix tracks the event so raise vs ack vs resolve are + // visually distinct in the ntfy notification list. + verb := "raised" + switch p.Event { + case EventAcknowledged: + verb = "ack" + case EventResolved: + verb = "resolved" + case EventTest: + verb = "test" + } req.Header.Set("Content-Type", "text/plain") - req.Header.Set("Title", fmt.Sprintf("[%s] %s %s", p.Severity, p.HostName, p.Kind)) - req.Header.Set("Tags", p.Severity+","+p.Kind) + req.Header.Set("Title", fmt.Sprintf("[%s · %s] %s %s", verb, p.Severity, p.HostName, p.Kind)) + req.Header.Set("Tags", verb+","+p.Severity+","+p.Kind) req.Header.Set("Priority", priorityForSeverity(p.Severity, c.defaultPriority)) if p.Link != "" { req.Header.Set("Click", p.Link) diff --git a/internal/notification/ntfy_test.go b/internal/notification/ntfy_test.go index 7aa2a0b..f75a118 100644 --- a/internal/notification/ntfy_test.go +++ b/internal/notification/ntfy_test.go @@ -60,13 +60,13 @@ func TestNtfySendsHeadersAndBody(t *testing.T) { t.Fatalf("want 200, got %d", code) } - if want := "[critical] alfa-01 check_failed"; gotTitle != want { + if want := "[raised · critical] alfa-01 check_failed"; gotTitle != want { t.Errorf("Title: got %q want %q", gotTitle, want) } if gotPri != "5" { t.Errorf("Priority: got %q want \"5\"", gotPri) } - if want := "critical,check_failed"; gotTags != want { + if want := "raised,critical,check_failed"; gotTags != want { t.Errorf("Tags: got %q want %q", gotTags, want) } if gotClick != "https://rm.example/a" { diff --git a/internal/notification/smtp.go b/internal/notification/smtp.go index 296bfdf..81771ba 100644 --- a/internal/notification/smtp.go +++ b/internal/notification/smtp.go @@ -117,9 +117,20 @@ func extractAddr(s string) string { // Plain text only; subject hardcoded. func buildEmailBody(cfg SMTPConfig, msgIDDomain string, p Payload) []byte { var b strings.Builder + // Subject prefix tracks the event verb so raise vs ack vs resolve + // are visually distinct in the inbox (and threaded by Message-ID). + verb := "raised" + switch p.Event { + case EventAcknowledged: + verb = "ack" + case EventResolved: + verb = "resolved" + case EventTest: + verb = "test" + } 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(fmt.Sprintf("Subject: [restic-manager] [%s · %s] %s: %s\r\n", verb, 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") diff --git a/internal/notification/smtp_test.go b/internal/notification/smtp_test.go index b3d3e06..59261af 100644 --- a/internal/notification/smtp_test.go +++ b/internal/notification/smtp_test.go @@ -133,7 +133,7 @@ func TestSMTPSendsExpectedHeaders(t *testing.T) { 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") { + if !strings.Contains(srv.data, "Subject: [restic-manager] [raised · warning] alfa-01: backup_failed") { t.Errorf("subject missing or wrong: %q", srv.data) } if !strings.Contains(srv.data, "Message-ID: <01ABC@rm.example>") {