hermes¶
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/Hermes
Full documentation (this README, the integration guide, and the architecture reference) is published at https://wikid82.github.io/Hermes/.
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/Hermes/providers/discord"
"github.com/Wikid82/Hermes/transport"
notify "github.com/Wikid82/Hermes"
)
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/Hermes"
_ "github.com/Wikid82/Hermes/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 viaRetryPolicy). - 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 inRequest.Headersis 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.