Integrating hermes into your project¶
This guide is for someone bringing this module into their own, unrelated project — not for working on this module itself (see ARCHITECTURE.md for that) and not specific to Charon, the project this module was originally extracted from.
Who this is for¶
A Go developer building a self-hosted or small-team application who needs to fire outbound alerts to chat/push/email destinations and doesn't want to hand-roll SSRF-safe HTTP dispatch, retries, or per-service payload quirks. As the README puts it: most projects that need this end up re-implementing the same things badly.
This is explicitly not for you if:
- You need an Apprise-style URL-scheme dispatcher (
discord://...) today — not yet implemented (see the README's "Project status" section for the long-term direction). - You need inbound or two-way messaging — this module is send-only.
What it does / doesn't do¶
Does:
- SSRF-safe outbound HTTP dispatch with retry/backoff (
transport.Wrapper) — destination validation, redirect re-validation, request/response size caps. - A uniform
Senderinterface across nine built-in provider types: Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, email, and direct browser Web Push. - JSON payload templating with a shared
text/templateengine plus atoJSONhelper. - A self-registering factory/discovery layer (
notify.Register/notify.New/notify.RegisteredTypes) for constructing aSenderby name at runtime.
Doesn't:
- Own any database, config file format, or HTTP framework.
- Provide inbound webhook receiving.
- Persist retries across a process restart — retries are in-process, in-request only.
- Provide a URL-scheme dispatch convention (yet).
When to reach for it vs. rolling your own¶
Reach for it if:
- You need two or more of {Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, email, webpush} dispatch.
- You want retry/backoff and SSRF hardening without writing it yourself.
- You're fine supplying your own HTTP client factory / SSRF policy / SMTP mailer via the module's dependency-injection seams (see "Why it's built this way" below).
Roll your own if:
- You need exactly one destination type with a very custom payload shape and don't want any of the shared machinery.
- You need transport types this module doesn't have (SMS, inbound webhooks, message queues).
Where it fits in a typical app's architecture¶
domain event startup (once)
│ │
▼ ▼
build a notify.Message build one shared *transport.Wrapper
│ (+ Mailer/TemplateRenderer if using email)
│ │
└──────────────┬──────────────────┘
▼
construct Sender(s): typed New(cfg, wrapper) directly,
OR notify.New(providerType, config) via providers/all
│
▼
sender.Send(ctx, msg)
│
▼
called from wherever your app fires alerts today
(a notification service, an error handler, a monitor loop)
This module is a dispatch layer your service layer calls into — it does not own your request lifecycle, your background job scheduler, or your config file format.
Why it's built this way¶
The module has zero database/framework/HTTP-server knowledge by design: every environment-specific
concern is a constructor-injected interface (transport.ClientFactory, transport.URLValidator,
email.Mailer, email.TemplateRenderer). This is what makes it equally usable from a Gin+GORM web
app, a CLI tool, or a serverless function — nothing in the module assumes any of those. It's also
why the provider registry (notify.New) accepts a generic map[string]any rather than forcing a
specific config-file format, struct tag convention, or framework binding: the registry boundary has
to work for both plain-data providers (Discord's WebhookURL string) and providers whose config
carries actual Go values a host constructs at startup (email's Mailer/TemplateRenderer
interfaces) — see ARCHITECTURE.md for the full rationale.
How to integrate — end-to-end walkthrough¶
1. Get the module:
go get github.com/Wikid82/Hermes@v0.2.0
2. Register the providers you want. Either blank-import everything for zero-touch discovery:
import _ "github.com/Wikid82/Hermes/providers/all"
or hand-pick individual providers if you'd rather control exactly what's linked into your binary (smaller binary, no unused transitive dependencies):
import (
_ "github.com/Wikid82/Hermes/providers/discord"
_ "github.com/Wikid82/Hermes/providers/email"
)
Both are equally supported; neither requires a different notify.Register/New API. Pick
providers/all if you want a new provider to become available automatically after a go get
version bump with no code change on your side; hand-pick if you'd rather that be a deliberate,
reviewed decision.
3. Build a shared transport.Wrapper once, at startup:
wrapper := transport.NewWrapper(
transport.WithAllowHTTP(false),
// transport.WithClientFactory / transport.WithURLValidator: supply your own
// SSRF-hardened HTTP client and destination-validation policy in production —
// see the README's "Bringing your own SSRF policy" section.
)
4. Construct a sender. Via notify.New for a runtime-determined provider type (e.g. one row of
provider config loaded from your database):
sender, err := notify.New("discord", map[string]any{
"transport": wrapper,
"webhook_url": "https://discord.com/api/webhooks/123456789/abcDEF",
"template": "minimal",
})
if err != nil {
log.Fatalf("unsupported or misconfigured provider: %v", err)
}
Email needs its Mailer (your own SMTP wrapper) constructed and passed directly — it isn't
plain data:
sender, err := notify.New("email", map[string]any{
"mailer": myMailer, // implements email.Mailer
"recipients": []string{"ops@example.com"},
"subject_prefix": "[MyApp] ",
})
Or skip the registry and call the typed constructor directly when you already know the provider type at compile time — this keeps full compile-time type safety and is the recommended path when you don't need runtime discovery:
sender := discord.New(discord.Config{WebhookURL: "https://discord.com/api/webhooks/..."}, wrapper)
providers/webpush (direct browser Web Push — no relay) follows the same typed-constructor shape,
but its Config mixes your application's VAPID identity (shared) with one subscriber's
PushSubscription destination (per-recipient) — see one webpush.New call per subscriber, reusing
the same VAPIDPublicKey/VAPIDPrivateKey/VAPIDSubject across all of them, to fan a single
notify.Message out to every subscriber your application has collected:
for _, sub := range subscriptions { // e.g. loaded from your own storage
sender := webpush.New(webpush.Config{
VAPIDPublicKey: vapidPublicKey, // same for every subscriber
VAPIDPrivateKey: vapidPrivateKey, // same for every subscriber
VAPIDSubject: "mailto:ops@example.com",
Endpoint: sub.Endpoint,
P256dh: sub.P256dh,
Auth: sub.Auth,
}, wrapper)
if err := sender.Send(ctx, msg); err != nil {
// Log the endpoint's host, not the full endpoint: for push services like
// FCM, sub.Endpoint's path commonly embeds a bearer-token-equivalent
// segment that shouldn't end up in your logs.
host := "unknown"
if u, parseErr := neturl.Parse(sub.Endpoint); parseErr == nil {
host = u.Host
}
log.Printf("webpush to %s failed: %v", host, err)
}
}
webpush.GenerateVAPIDKeyPair() generates VAPIDPublicKey/VAPIDPrivateKey once at application
setup time; persist the result yourself (rotating it invalidates every subscription already
collected, since the browser binds each subscription to the exact public key it was created with).
5. Dispatch a message:
err = sender.Send(ctx, 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},
})
6. Handle/log the error. Send never panics; a failure (bad destination, provider rejected the
payload, network error after retries) is always a returned error:
if err != nil {
log.Printf("notify: failed to send to discord: %v", err)
// decide your own policy: retry later, alert on a fallback channel, just log — this
// module makes no decision for you beyond in-request retry/backoff.
}
Testing your integration: see the README's "Testing your own integration" section for how to inject a fake HTTP round-tripper and a passthrough URL validator so your tests never touch the network.