package ntfy import ( "context" "fmt" "io" "log/slog" "net/http" "strings" "time" ) type Client struct { BaseURL string Token string // Username/Password take precedence over Token when set, using HTTP Basic // auth (for ntfy servers that authenticate with user accounts rather than // access tokens). Username string Password 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 token authentication. func NewWithToken(baseURL, token string) *Client { c := New(baseURL) c.Token = strings.TrimSpace(token) return c } // NewWithBasicAuth returns a ntfy Client that authenticates publishes with an // ntfy user account (HTTP Basic auth). Use this for servers configured with // auth-default-access: deny-all and per-user topic ACLs. func NewWithBasicAuth(baseURL, username, password string) *Client { c := New(baseURL) c.Username = strings.TrimSpace(username) c.Password = password 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.Username != "" { req.SetBasicAuth(c.Username, c.Password) } else 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) } authMode := "none" switch { case c.Username != "": authMode = "basic" case c.Token != "": authMode = "bearer" } slog.Info("ntfy publish", "url", url, "topic", n.Topic, "auth", authMode, "user", c.Username, ) 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 }