feat(channels): include event verb in ntfy title + smtp subject
CI / Build (windows/amd64) (pull_request) Successful in 30s
CI / Build (linux/amd64) (pull_request) Successful in 23s
CI / Build (linux/arm64) (pull_request) Successful in 22s
CI / Lint (pull_request) Successful in 1m19s
CI / Test (linux/amd64) (pull_request) Successful in 1m42s

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 · <message>' / 'Acknowledged · <message>' 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'.
This commit is contained in:
2026-05-04 23:17:42 +01:00
parent 84814b1386
commit 43e72b0d5b
4 changed files with 38 additions and 7 deletions
+23 -3
View File
@@ -58,14 +58,34 @@ func (c *NtfyChannel) Send(ctx context.Context, p Payload) (int, time.Duration,
server := strings.TrimRight(c.cfg.ServerURL, "/") server := strings.TrimRight(c.cfg.ServerURL, "/")
url := server + "/" + c.cfg.Topic 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 { if err != nil {
return 0, 0, fmt.Errorf("ntfy: build request: %w", err) 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("Content-Type", "text/plain")
req.Header.Set("Title", fmt.Sprintf("[%s] %s %s", p.Severity, p.HostName, p.Kind)) req.Header.Set("Title", fmt.Sprintf("[%s · %s] %s %s", verb, p.Severity, p.HostName, p.Kind))
req.Header.Set("Tags", p.Severity+","+p.Kind) req.Header.Set("Tags", verb+","+p.Severity+","+p.Kind)
req.Header.Set("Priority", priorityForSeverity(p.Severity, c.defaultPriority)) req.Header.Set("Priority", priorityForSeverity(p.Severity, c.defaultPriority))
if p.Link != "" { if p.Link != "" {
req.Header.Set("Click", p.Link) req.Header.Set("Click", p.Link)
+2 -2
View File
@@ -60,13 +60,13 @@ func TestNtfySendsHeadersAndBody(t *testing.T) {
t.Fatalf("want 200, got %d", code) 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) t.Errorf("Title: got %q want %q", gotTitle, want)
} }
if gotPri != "5" { if gotPri != "5" {
t.Errorf("Priority: got %q want \"5\"", gotPri) 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) t.Errorf("Tags: got %q want %q", gotTags, want)
} }
if gotClick != "https://rm.example/a" { if gotClick != "https://rm.example/a" {
+12 -1
View File
@@ -117,9 +117,20 @@ func extractAddr(s string) string {
// Plain text only; subject hardcoded. // Plain text only; subject hardcoded.
func buildEmailBody(cfg SMTPConfig, msgIDDomain string, p Payload) []byte { func buildEmailBody(cfg SMTPConfig, msgIDDomain string, p Payload) []byte {
var b strings.Builder 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("From: " + cfg.From + "\r\n")
b.WriteString("To: " + cfg.To + "\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("Date: " + p.RaisedAt.UTC().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: <" + p.AlertID + "@" + msgIDDomain + ">\r\n") b.WriteString("Message-ID: <" + p.AlertID + "@" + msgIDDomain + ">\r\n")
b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("MIME-Version: 1.0\r\n")
+1 -1
View File
@@ -133,7 +133,7 @@ func TestSMTPSendsExpectedHeaders(t *testing.T) {
if !strings.Contains(srv.rcptTo, "ops@example.com") { if !strings.Contains(srv.rcptTo, "ops@example.com") {
t.Errorf("RCPT TO: %q", srv.rcptTo) 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) t.Errorf("subject missing or wrong: %q", srv.data)
} }
if !strings.Contains(srv.data, "Message-ID: <01ABC@rm.example>") { if !strings.Contains(srv.data, "Message-ID: <01ABC@rm.example>") {