package ntfy import ( "context" "fmt" "io" "log/slog" "net/http" "strings" "time" ) type Client struct { BaseURL string Token string HTTP *http.Client } func New(baseURL string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), HTTP: &http.Client{Timeout: 15 * time.Second}, } } // NewWithToken returns a ntfy Client with bearer-token auth set. Use this // when the ntfy server requires authentication. func NewWithToken(baseURL, token string) *Client { c := New(baseURL) c.Token = strings.TrimSpace(token) return c } type Notification struct { Topic string Title string Message string Priority string Tags []string Click string } // Send publishes to ntfy using the topic-path + header style // (POST {base}/{topic} with metadata in HTTP headers and the message as the // raw body). This is the most broadly compatible ntfy publish method — // works on every ntfy version including self-hosted, and on any path layout // the server is mounted under. func (c *Client) Send(ctx context.Context, n Notification) error { if c.BaseURL == "" { return fmt.Errorf("ntfy base_url not configured") } if n.Topic == "" { return fmt.Errorf("ntfy topic required") } url := c.BaseURL + "/" + strings.TrimLeft(n.Topic, "/") req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(n.Message)) if err != nil { return err } req.Header.Set("Content-Type", "text/plain; charset=utf-8") if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } if n.Title != "" { req.Header.Set("Title", n.Title) } if n.Priority != "" { req.Header.Set("Priority", n.Priority) } if len(n.Tags) > 0 { req.Header.Set("Tags", strings.Join(n.Tags, ",")) } if n.Click != "" { req.Header.Set("Click", n.Click) } tokenLen := len(c.Token) tokenPrefix := "" if tokenLen >= 4 { tokenPrefix = c.Token[:4] } slog.Info("ntfy publish", "url", url, "topic", n.Topic, "auth_header_set", c.Token != "", "token_prefix", tokenPrefix, "token_len", tokenLen, ) resp, err := c.HTTP.Do(req) if err != nil { return fmt.Errorf("ntfy POST: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("ntfy returned %d: %s", resp.StatusCode, string(b)) } return nil }