Skip to content

Repository files navigation

go_notify_yourself

A standalone, dependency-free Go module for notification delivery: SSRF-safe outbound HTTP dispatch with retries, and a common Sender interface across Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhooks, email, and direct browser Web Push.

go get github.com/Wikid82/go_notify_yourself

Why

Most projects that need to fire off a Discord/Slack/email alert end up re-implementing the same things badly: no retry on transient failures, no SSRF protection on the outbound URL, ad hoc JSON templating, and provider-specific quirks (auth headers vs. query params vs. URL path segments) scattered across the codebase. This module packages that once, behind a small, uniform API, so a project can wire up outbound notifications without re-solving delivery mechanics from scratch.

Design principle: this module never assumes anything about your environment. It has no database, no HTTP framework, no config system of its own. Every environment-specific concern (the HTTP client, SSRF policy, SMTP transport, HTML templates) is a constructor-injected interface you supply — see Bringing your own SSRF policy below.

Quick start

package main

import (
	"context"
	"log"

	"github.com/Wikid82/go_notify_yourself/providers/discord"
	"github.com/Wikid82/go_notify_yourself/transport"

	notify "github.com/Wikid82/go_notify_yourself"
)

func main() {
	// One shared transport.Wrapper per process is typical — every provider
	// package dispatches through it.
	wrapper := transport.NewWrapper(transport.WithAllowHTTP(false))

	sender := discord.New(discord.Config{
		WebhookURL: "https://discord.com/api/webhooks/123456789/abcDEF",
	}, wrapper)

	err := sender.Send(context.Background(), notify.Message{
		Title:     "Disk usage high",
		Body:      "Volume /data is at 92% capacity.",
		EventType: "disk_usage",
		Data:      map[string]any{"Host": "db-01", "Percent": 92},
	})
	if err != nil {
		log.Fatal(err)
	}
}

Every provider package follows the same shape: a Config struct, New(cfg, wrapper) *Client, and (*Client).Send(ctx, notify.Message) error — implementing the shared notify.Sender interface, so a caller that fans a notification out to several configured destinations can treat them uniformly:

senders := []notify.Sender{discordSender, slackSender, webhookSender}
for _, s := range senders {
	if err := s.Send(ctx, msg); err != nil {
		log.Printf("notify: %v", err)
	}
}

The notify.Message type

type Message struct {
	Title     string         // short headline
	Body      string         // human-readable message text
	EventType string         // free-form, host-defined category (opaque to this module)
	Timestamp time.Time      // defaults to time.Now() if zero
	Data      map[string]any // arbitrary structured extras, exposed to templates as {{toJSON .Data}}
}

Data is where your application's own domain fields go (hostnames, IDs, counts, whatever) — this module has no opinion on your event vocabulary.

Provider packages

Package Config fields (beyond Template/CustomTemplate) Notes
providers/discord WebhookURL Validates the URL is a real discord.com/canary.discord.com webhook. Normalizes payload to content/embeds.
providers/slack WebhookURL Validates against Slack's hooks.slack.com/services/T.../B.../... shape. Normalizes payload to text/blocks.
providers/gotify URL, Token Sends Token as X-Gotify-Key.
providers/pushover UserKey, APIToken, BaseURL (optional override) Injects token/user into the payload. Rejects emergency priority (2) — not yet supported.
providers/ntfy URL, Token Sends Token as Authorization: Bearer <token>.
providers/telegram BotToken, ChatID, BaseURL (optional override) Bot token is embedded in the dispatch URL path per Telegram's own API convention; injects chat_id.
providers/webhook URL Generic/custom JSON dispatch — no destination allowlist, no payload field requirements. Also exposes RenderPreview for validating a custom template without dispatching.
providers/email see below The one provider not built on transport.Wrapper — see Email.
providers/webpush VAPIDPublicKey, VAPIDPrivateKey, VAPIDSubject, Endpoint, P256dh, Auth, TTL (optional), Urgency/Topic (optional) Direct browser Web Push delivery (RFC 8030/8291/8292) — no third-party relay. Encrypts the payload per RFC 8291 (aes128gcm) and signs an RFC 8292 VAPID JWT per request; no Config.Message field requirement, since the payload shape is entirely up to the receiving service worker.

Every HTTP-based provider's Config.Template selects the JSON payload shape: "minimal" (default), "detailed", or "custom" (uses Config.CustomTemplate, a Go text/template string with a toJSON helper function available, e.g. {{toJSON .Message}}).

Provider registry

Every provider package self-registers with a small, generic factory registry at the module root, mirroring the database/sql driver pattern: notify.Register(name, factory) is called from each package's init(), and notify.New(name, config) looks up and constructs a Sender by name at runtime — useful when the provider type is only known at runtime (e.g. loaded from a database row or config file), not hardcoded at compile time.

import (
	notify "github.com/Wikid82/go_notify_yourself"
	_ "github.com/Wikid82/go_notify_yourself/providers/all" // registers all 9 built-in providers
)

wrapper := transport.NewWrapper()
sender, err := notify.New("discord", map[string]any{
	"transport":   wrapper,
	"webhook_url": "https://discord.com/api/webhooks/123456789/abcDEF",
})

notify.RegisteredTypes() returns the sorted list of provider names currently linked into your binary — handy for populating a UI dropdown or validating a config value against exactly what's compiled in, without hardcoding your own list.

The registry is an additive convenience/discovery layer, not a replacement for the typed constructors — discord.New(discord.Config{...}, wrapper) remains the recommended path when you don't need runtime discovery, since it keeps full compile-time type safety. See ARCHITECTURE.md for how to add a new provider to the registry, and docs/INTEGRATION.md for a full integration walkthrough into your own project.

Transport: SSRF-safe dispatch with retries

transport.Wrapper is the shared delivery primitive every HTTP-based provider package dispatches through:

  • Retries on 429/5xx/transient network errors with exponential backoff + jitter (configurable via RetryPolicy).
  • Validates the destination URL (and every redirect target) through a pluggable URLValidator.
  • Caps request bodies at 256 KiB and response bodies at 1 MiB.
  • Allowlists outbound headers (Content-Type, User-Agent, X-Request-ID, X-Gotify-Key, Authorization) — anything else you pass in Request.Headers is silently dropped.
  • Rejects destination URLs carrying credentials, fragments, or common auth-looking query parameters (token, auth, apikey, api_key).
wrapper := transport.NewWrapper(
	transport.WithAllowHTTP(false),          // reject plain HTTP (and loopback) destinations
	transport.WithRetryPolicy(transport.RetryPolicy{
		MaxAttempts: 5,
		BaseDelay:   250 * time.Millisecond,
		MaxDelay:    5 * time.Second,
	}),
)

Bringing your own SSRF policy

With no options, NewWrapper() uses a conservative, dependency-free built-in DefaultURLValidator (rejects non-HTTPS unless allowHTTP, rejects IP literals and DNS-resolved addresses in RFC 1918 / loopback / link-local / other reserved ranges) and a plain *http.Client with no additional hardening. That's enough to be useful standalone, but a production host with its own SSRF infrastructure (DNS-rebinding protection at dial time, a shared IP-blocklist, etc.) should override both:

wrapper := transport.NewWrapper(
	transport.WithClientFactory(func(allowHTTP bool, maxRedirects int) *http.Client {
		return myapp.NewSafeHTTPClient(allowHTTP, maxRedirects) // your own hardened client
	}),
	transport.WithURLValidator(func(rawURL string, allowHTTP bool) (string, error) {
		return myapp.ValidateExternalURL(rawURL, allowHTTP) // your own SSRF policy
	}),
)

Email

Unlike the HTTP providers, providers/email never dials SMTP and never renders HTML by default — both are host-supplied interfaces, since this module has no opinion on your mail transport or branding:

type Mailer interface {
	Send(ctx context.Context, recipients []string, subject, htmlBody string) error
}

type TemplateRenderer interface {
	Render(templateName string, msg notify.Message) (htmlBody string, err error)
}
sender := email.New(email.Config{
	Recipients:    []string{"ops@example.com"},
	SubjectPrefix: "[MyApp] ", // "" by default — no branding baked in
	Mailer:        myMailer,   // required: your SMTP wrapper
	// Renderer and TemplateName are optional — omit them to use the
	// built-in neutral, unbranded HTML template.
})

If you don't supply a Renderer, Send renders one neutral, inline-styled HTML template (no logo, no product name) — good enough to get working email out of a fresh integration with zero config. Supply your own Renderer/TemplateName to plug in branded, event-differentiated templates.

Testing your own integration

Every provider's Config takes a *transport.Wrapper, and transport.NewWrapper takes a ClientFactory — so tests can inject a fake http.RoundTripper that fabricates responses without touching the network, and a passthrough URLValidator that skips SSRF checks against a local test server. See any providers/*/*_test.go file in this repo for the pattern.

Project status

Extracted from Charon's internal notification engine. The original provider list was intentionally exactly the seven HTTP providers plus email ported from Charon — see docs/plans/notifications_extraction_spec.md in Charon's repo for the extraction design brief. providers/webpush (issue #14) is the one deliberate, maintainer-approved exception to "no new providers without an explicit ask": it's a genuinely different delivery mechanism (direct browser push, not a relay) with no Apprise equivalent, not a straight port — see docs/plans/current_spec.md for its own design brief. Long-term direction is an Apprise-style common interface over a larger provider catalog; the Sender interface and per-package structure here are deliberately shaped so that's additive later, not a breaking rework.

License

MIT — see LICENSE.

Releases

Sponsor this project

Packages

Contributors

Languages