herald

package module
v1.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: MIT Imports: 27 Imported by: 2

README

Herald, a Go notification library for email, SMS, push and in-app messages with templates and i18n

Herald: multi-channel notifications for Go

Herald is a Go library for sending notifications over email, SMS, push, in-app, chat and webhooks from one API. You store providers (SMTP, Resend, SendGrid, Twilio, FCM, APNs, Slack and more), write templates with a version per locale, and Herald picks the provider through app, org and user routing rules, skips users who opted out, logs every message, and keeps an in-app inbox per user. It runs inside your process on PostgreSQL, SQLite, MongoDB or memory, and it ships an extension for Forge with a REST API.

CI Go Reference GitHub Stars

Documentation · Quick start · Features · Drivers · Templates · Forge · Configuration · FAQ

Quick start

You need Go 1.26 or later.

go get github.com/xraph/herald@latest

This program needs no mail server or API key. It uses the in-memory store and the in-app driver, creates a template in English and French, and sends it to one user's inbox:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/xraph/herald"
	"github.com/xraph/herald/driver/inapp"
	"github.com/xraph/herald/id"
	"github.com/xraph/herald/store/memory"
	"github.com/xraph/herald/template"
)

func main() {
	ctx := context.Background()

	h, err := herald.New(
		herald.WithStore(memory.New()),
		herald.WithDriver(&inapp.Driver{}),
	)
	if err != nil {
		log.Fatal(err)
	}

	// The in-app driver needs no credentials, so Herald can create its provider.
	if err := h.SeedDefaultProviders(ctx, "my-app"); err != nil {
		log.Fatal(err)
	}

	// One template, two locales.
	tmpl := &template.Template{
		ID:      id.NewTemplateID(),
		AppID:   "my-app",
		Slug:    "order.shipped",
		Name:    "Order shipped",
		Channel: "inapp",
		Enabled: true,
	}
	if err := h.Store().CreateTemplate(ctx, tmpl); err != nil {
		log.Fatal(err)
	}
	for _, v := range []template.Version{
		{Locale: "en", Title: "Order {{.order}} shipped", Text: "It arrives {{.eta}}."},
		{Locale: "fr", Title: "Commande {{.order}} expédiée", Text: "Livraison prévue {{.eta}}."},
	} {
		v.ID = id.NewTemplateVersionID()
		v.TemplateID = tmpl.ID
		v.Active = true
		if err := h.Store().CreateVersion(ctx, &v); err != nil {
			log.Fatal(err)
		}
	}

	res, err := h.Send(ctx, &herald.SendRequest{
		AppID:    "my-app",
		UserID:   "user-42",
		Channel:  "inapp",
		Template: "order.shipped",
		Locale:   "fr-CA", // falls back to "fr"
		To:       []string{"user-42"},
		Data:     map[string]any{"order": "#1001", "eta": "jeudi"},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("status:", res.Status)

	inbox, err := h.Store().ListNotifications(ctx, "my-app", "user-42", 10, 0)
	if err != nil {
		log.Fatal(err)
	}
	for _, n := range inbox {
		fmt.Printf("inbox: %s / %s\n", n.Title, n.Body)
	}
}

Run it with go run .. After a log line about the seeded provider, you'll see:

status: sent
inbox: Commande #1001 expédiée / Livraison prévue jeudi.

The request asked for fr-CA. There's no version for that, so Herald used the fr one. Templates belong to a channel, so for email you'd create the same template with Channel: "email", register an email driver, add a provider for it, and send the same request with Channel: "email" and an address in To.

Why Herald?

You probably started with one email provider called straight from a handler. Then someone asks for SMS for one customer, push on mobile, a bell icon in the web app, French copy and a way for users to turn off marketing mail, and before long your notification code is spread across five services that each know a little about your vendors. Herald puts that in one place. One request type, every channel.

You need Herald gives you
One API for every channel Send for one channel and Notify to send the same template on several, across email, sms, push, inapp, chat and webhook
Swappable providers Providers stored per app with a driver, credentials, settings and a priority, managed with CreateProvider, UpdateProvider and DeleteProvider
Per-customer routing Scoped configs at app, org and user level that pick the provider and the sender address; the resolver tries user, then org, then app, then the enabled provider with the lowest priority number
Templates with i18n Go templates with one version per locale, required variables with defaults, and fallback from fr-CA to fr to the default version
Ready-made auth emails SeedDefaultTemplates adds welcome, verification, password reset, sign-in alert, MFA and organization emails
User opt-outs Per-user preferences by notification type and channel; an opted-out send is logged as suppressed and nothing goes out
An in-app inbox Notifications stored per user, with unread counts and mark-as-read
A delivery log One message row per recipient, with status, error and the vendor's message ID
Encrypted credentials AES-256-GCM for provider credentials, with key rotation through previous keys
An audit trail Audit events for sends and suppressions, through Chronicle or slog

Channels and drivers

Each provider you store names a driver, and you register that driver on the engine with herald.WithDriver. Six come in the root module:

Driver Channel Package Credentials and settings
smtp email github.com/xraph/herald/driver/email (&email.SMTPDriver{}) username, password; settings host, port, use_tls
resend email github.com/xraph/herald/driver/email (&email.ResendDriver{}) api_key
file email github.com/xraph/herald/driver/email (&email.FileDriver{}) setting dir; writes each email there as an .eml file, for local development
twilio sms github.com/xraph/herald/driver/sms (&sms.TwilioDriver{}) account_sid, auth_token; setting from_number
fcm push github.com/xraph/herald/driver/push (&push.FCMDriver{}) access_token or server_key; setting project_id
inapp inapp github.com/xraph/herald/driver/inapp (&inapp.Driver{}) none

The rest are separate Go modules, so you only download the vendors you actually use:

Driver Channel Module
sendgrid, mailgun, postmark, ses, cloudflare email github.com/xraph/herald/drivers/<name>
vonage, messagebird sms github.com/xraph/herald/drivers/<name>
apns push github.com/xraph/herald/drivers/apns
slack, discord chat github.com/xraph/herald/drivers/<name>
webhook webhook github.com/xraph/herald/drivers/webhook, which can deliver through Relay for retries and signing
go get github.com/xraph/herald/drivers/slack@latest

Each driver exports a Driver type (&slack.Driver{}), except webhook, which you build with webhook.New(r) and a *relay.Relay or nil. A driver's Fields() says which keys are credentials and which are settings, and Herald refuses a provider that puts a setting such as host or base_url in its credentials.

Adding an SMTP provider looks like this:

err := h.CreateProvider(ctx, &provider.Provider{
	AppID:    "my-app",
	Name:     "Mailpit",
	Channel:  "email",
	Driver:   "smtp",
	Settings: map[string]string{"host": "localhost", "port": "1025", "from": "hello@example.com"},
	Enabled:  true,
})

Watch this one. A failed delivery doesn't come back as an error from Send: it comes back in the result, with Status set to failed and the vendor's reason in Error, and the message log keeps the same record so you can look it up later. Send returns an error when it can't get as far as a driver: no provider for the channel, a missing or disabled template, a driver that isn't registered.

Templates and locales

A template belongs to an app and a channel, has a slug, and carries one Version per locale with any of Subject, HTML, Text and Title. You write them as Go templates. Subject, text and title render with text/template, HTML renders with html/template, and you can call upper, lower, title, truncate, default, now and formatDate from either. If you declare Variables on the template, you can mark some as required and give the others a default.

When you send with Locale: "fr-CA", Herald looks for an active fr-CA version, then fr, then the version whose locale is "". An empty Locale on the request means the engine's default, en unless you set herald.WithDefaultLocale.

You don't have to use a template. Leave Template empty, or set Body, and Send uses your Subject and Body as they are.

Preferences and the inbox

A preference.Preference holds, per user, overrides keyed by notification type (the template slug) with a flag per channel. Store one with h.Store().SetPreference(ctx, p). From then on, whenever a send carries both a UserID and a Template and that user has turned the channel off for that template, Herald writes the message to the log as suppressed, records an audit event, returns the suppressed result to you and never calls a driver, so the opt-out holds even when the code doing the sending has no idea it exists.

An inapp send with a UserID adds a notification to that user's inbox. Read it back with ListNotifications, UnreadCount, MarkRead and MarkAllRead on the store, or through the REST API.

Pick a store

herald.New needs a store and returns ErrNoStore without one.

Store Package
In-memory github.com/xraph/herald/store/memory
PostgreSQL github.com/xraph/herald/store/postgres, on a *grove.DB with Grove's pgdriver
SQLite github.com/xraph/herald/store/sqlite, on a *grove.DB with Grove's sqlitedriver
MongoDB github.com/xraph/herald/store/mongo, on a *grove.DB with Grove's mongodriver

Call Migrate on a database store before you send, unless you're on the Forge extension, which does it for you. Every store passes the same conformance suite in store/storetest, and you can run it against a store of your own.

Use it with Forge

The extension package mounts Herald in a Forge app:

import (
	"github.com/xraph/forge"
	heraldext "github.com/xraph/herald/extension"
	"github.com/xraph/herald/store/memory"
)

app := forge.NewApp(forge.AppConfig{
	Name: "notifications",
	Extensions: []forge.Extension{
		heraldext.New(
			heraldext.WithStore(memory.New()),
			heraldext.WithAPIMiddleware(requireAdmin), // your auth middleware
		),
	},
})

During Register the extension:

  1. Picks a store. A *grove.DB from WithGroveDatabase(name) or grove_database comes first, then the default *grove.DB in the container, then WithStore. A grove store wins over WithStore even when you never asked for grove, so in an app that also runs the Grove extension your WithStore store is ignored unless there's no default grove database.
  2. Uses a chronicle.Emitter from the container for audit events if there is one, and slog if not.
  3. Registers the seven built-in drivers. Add others with heraldext.WithDriver.
  4. Runs migrations, seeds the in-app provider and any providers from your config, and mounts the REST API under /herald.
  5. Provides *herald.Herald to the container, so you can resolve it with forge.Inject[*herald.Herald](app.Container()).

The REST API lives under /herald/v1: providers, templates (with versions), send, notify, messages, inbox, preferences and config for routing rules. With no middleware, the extension logs a warning, because anyone who can reach the API can read and change notification data. Pass your auth middleware with WithAPIMiddleware.

Every send is traced and measured as well. The extension hands app.Metrics() to Herald, so forge_herald_deliveries_attempted_total, forge_herald_delivery_duration_seconds and three more series show up on /_/metrics, and each send is a send notification span with the template render, the provider lookup and the vendor call under it. Spans never carry a recipient, the message, a credential or a vendor URL, and Herald sends vendors no trace headers. Outside Forge, pass herald.WithMetrics and install a tracer provider yourself. Observability lists every span and metric.

The extension also registers a contract contributor for the Forge dashboard. MIGRATION.md explains what replaced the old templ dashboard.

Configuration

The extension reads extensions.herald and falls back to a top-level herald key:

extensions:
  herald:
    base_path: /herald
    disable_routes: false
    disable_migrate: false
    grove_database: ""
    credentials_key: "<base64 of 32 random bytes>"
    credentials_key_id: "2026-10"
    previous_credentials_keys:
      - id: "2026-01"
        key: "<base64>"
    providers:
      - name: Resend
        app_id: my-app
        channel: email
        driver: resend
        credentials:
          api_key: re_xxx
        settings:
          from: hello@example.com
        priority: 0

Providers listed under providers are created once, if no provider with that name exists for the app yet, so edits you make later through the API or dashboard stick. With credentials_key set, new credentials are stored encrypted, and previous_credentials_keys lets Herald read rows written under an older key. In code, the same key goes in with herald.WithCredentialKey and herald.WithPreviousCredentialKey.

The extension options are WithStore, WithGroveDatabase, WithDriver, WithBasePath, WithDisableRoutes, WithDisableMigrate, WithAPIMiddleware, WithConfig, WithRequireConfig and WithHeraldOption for a raw engine option.

The engine settings that come from the embedded herald.Config (DefaultLocale, MaxBatchSize, TruncateBodyAt) carry no YAML tags today, so they don't bind from a config file. WithHeraldOption(herald.WithDefaultLocale("fr")) doesn't work for them either: the extension fills DefaultLocale and MaxBatchSize from its own defaults and appends those options after yours, so the locale ends up en. Set them through WithConfig, starting from the defaults:

cfg := heraldext.DefaultConfig()
cfg.DefaultLocale = "fr"

heraldext.New(
	heraldext.WithConfig(cfg),
	heraldext.WithStore(memory.New()),
)

WithConfig replaces the whole config, so put it before the options that write into it. TruncateBodyAt never reaches the engine, which always truncates logged bodies at 4096 characters, and nothing reads MaxBatchSize yet. Keep WithHeraldOption for options the config doesn't cover, such as herald.WithLogger.

Documentation

If you want to Read
Send a first notification Getting started, full example
Set up vendors Providers, drivers
Write and translate messages Templates
Route per customer Scoped config, multi-tenant patterns
Respect opt-outs and build a bell icon Preferences, inbox
Run it on a database PostgreSQL, SQLite, MongoDB
Mount it in Forge Forge extension guide, HTTP API
Trace and alert on sends Observability

The Go API is on pkg.go.dev. CHANGELOG.md lists what changed in each release, breaking changes first.

FAQ

How do I send email and SMS from Go with one API?

Register an email driver and an SMS driver, create a provider for each, and call h.Send with Channel: "email" or Channel: "sms". To send the same template on both at once, call h.Notify with Channels: []string{"email", "sms"}. You get one result per channel.

How does Herald choose a provider?

It tries a routing rule for the user, then the org, then the app. If none names a provider for the channel, it takes the enabled provider with the lowest Priority. h.ResolveProvider tells you which one would send and why, and PreviewSend shows the provider and sender without sending anything. You can skip the resolver by setting ProviderID on the request.

Can I translate notification templates?

Yes. Add one template version per locale. A request for pt-BR uses the pt-BR version if there is one, then pt, then the version with an empty locale.

Can I use Herald without Forge?

Yes. Everything in the quick start is plain Go. Only the extension, extension/contract and api packages need Forge.

Are provider API keys stored in plain text?

Only if you don't configure a key. Set credentials_key (or herald.WithCredentialKey) and Herald encrypts credentials with AES-256-GCM before it stores them. EncryptStoredCredentials encrypts rows written before you had a key, and the REST API never returns credential values.

Contributing

Issues and focused pull requests are welcome on GitHub. Run Go commands with GOWORK=off:

GOWORK=off go build ./...
GOWORK=off go test -race ./...                   # root module only
make check                                       # fmt, vet and lint
cd drivers/slack && GOWORK=off go test -race ./...   # each driver is its own module

./... stops at every nested go.mod, so the root commands never touch drivers/, and CI doesn't test them either. If you change root API a driver uses, test that driver yourself. The PostgreSQL and MongoDB store suites skip unless you set HERALD_TEST_POSTGRES_DSN and HERALD_TEST_MONGO_URI. AGENTS.md has the details, the lint rules and how releases tag the root and every driver. Skip make build, make run and make all, which point at a cmd/herald that doesn't exist. Commits follow Conventional Commits, and user-visible changes go in CHANGELOG.md.

License

MIT. See LICENSE.

Herald is part of the forgery stack built on Forge, by Rex Raphael and contributors.

Documentation

Overview

Package herald provides a unified, multi-channel notification delivery engine.

Herald supports email, SMS, push notifications, and in-app notifications with pluggable provider drivers, a template system with i18n support, and scoped configuration overrides (app → org → user).

Index

Constants

View Source
const (
	ProtectionAESGCM    = credential.ProtectionAESGCM
	ProtectionPlaintext = credential.ProtectionPlaintext
)

Protection values reported per credential.

Variables

View Source
var (
	// ErrNoStore is returned when a Herald instance is created without a store.
	ErrNoStore = errors.New("herald: store is required")

	// ErrProviderNotFound is returned when a provider cannot be found.
	ErrProviderNotFound = store.ErrProviderNotFound

	// ErrProviderDisabled is returned when the resolved provider is disabled.
	ErrProviderDisabled = errors.New("herald: provider is disabled")

	// ErrNoProviderConfigured is returned when no provider is configured for the requested channel.
	ErrNoProviderConfigured = errors.New("herald: no provider configured for channel")

	// ErrDriverNotFound is returned when a driver is not registered.
	ErrDriverNotFound = errors.New("herald: driver not found")

	// ErrTemplateNotFound is returned when a template cannot be found.
	ErrTemplateNotFound = store.ErrTemplateNotFound

	// ErrVersionNotFound is returned when a template version cannot be found.
	ErrVersionNotFound = store.ErrVersionNotFound

	// ErrTemplateDisabled is returned when the resolved template is disabled.
	ErrTemplateDisabled = errors.New("herald: template is disabled")

	// ErrNoVersionForLocale is returned when no template version matches the requested locale.
	ErrNoVersionForLocale = template.ErrNoVersionForLocale

	// ErrTemplateRenderFailed is returned when template rendering fails.
	ErrTemplateRenderFailed = template.ErrTemplateRenderFailed

	// ErrMissingRequiredVariable is returned when a required template variable is not provided.
	ErrMissingRequiredVariable = template.ErrMissingRequiredVariable

	// ErrMessageNotFound is returned when a message cannot be found.
	ErrMessageNotFound = store.ErrMessageNotFound

	// ErrInboxNotFound is returned when an in-app notification cannot be found.
	ErrInboxNotFound = store.ErrNotificationNotFound

	// ErrPreferenceNotFound is returned when user preferences cannot be found.
	ErrPreferenceNotFound = store.ErrPreferenceNotFound

	// ErrScopedConfigNotFound is returned when no scoped config is found.
	ErrScopedConfigNotFound = store.ErrScopedConfigNotFound

	// ErrInvalidChannel is returned when an unsupported channel type is specified.
	ErrInvalidChannel = errors.New("herald: invalid channel type")

	// ErrSendFailed is returned when notification delivery fails.
	ErrSendFailed = errors.New("herald: send failed")

	// ErrOptedOut is returned when the user has opted out of this notification type.
	ErrOptedOut = errors.New("herald: user opted out")

	// ErrStoreClosed is returned when a store operation is attempted after close.
	ErrStoreClosed = errors.New("herald: store is closed")

	// ErrMigrationFailed is returned when a database migration fails.
	ErrMigrationFailed = errors.New("herald: migration failed")

	// ErrDuplicateSlug is returned when a template slug+channel+app combination already exists.
	ErrDuplicateSlug = store.ErrDuplicateSlug

	// ErrDuplicateLocale is returned when a template version for the same locale already exists.
	ErrDuplicateLocale = store.ErrDuplicateLocale

	// ErrInvalidProvider wraps a provider that failed validation. The message
	// names the problem and never a credential value.
	ErrInvalidProvider = errors.New("herald: invalid provider")

	// ErrNoCredentialKey is returned by operations that need a credential key
	// when none is configured.
	ErrNoCredentialKey = errors.New("herald: no credential key is configured")

	// ErrCredentialKeyUnavailable means a credential was encrypted under a key
	// that isn't configured. The message names the key ID.
	ErrCredentialKeyUnavailable = credential.ErrKeyUnavailable

	// ErrNoDeviceStore is returned by the device methods when neither the main
	// store nor WithDeviceStore provides a device store.
	ErrNoDeviceStore = errors.New("herald: no device store is configured")

	// ErrDeviceNotFound is returned when a device does not exist for the app.
	ErrDeviceNotFound = device.ErrNotFound
)

Sentinel errors returned by Herald operations.

Functions

This section is empty.

Types

type ChannelType

type ChannelType string

ChannelType represents a notification delivery channel.

const (
	ChannelEmail   ChannelType = "email"
	ChannelSMS     ChannelType = "sms"
	ChannelPush    ChannelType = "push"
	ChannelInApp   ChannelType = "inapp"
	ChannelWebhook ChannelType = "webhook"
	ChannelChat    ChannelType = "chat"
)

Supported notification channels.

func ValidChannels

func ValidChannels() []ChannelType

ValidChannels returns all valid channel types.

func (ChannelType) IsValid

func (c ChannelType) IsValid() bool

IsValid reports whether c is a recognized channel type.

func (ChannelType) String

func (c ChannelType) String() string

String returns the string representation.

type Config

type Config struct {
	// DefaultLocale is the default locale for template rendering (default: "en").
	DefaultLocale string

	// MaxBatchSize is the maximum number of notifications per batch send.
	MaxBatchSize int

	// TruncateBodyAt is the maximum message body length stored in the delivery log.
	// Bodies longer than this are truncated. 0 means no truncation.
	TruncateBodyAt int
}

Config holds the configuration for a Herald instance.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns a Config with sensible defaults.

type CredentialState

type CredentialState struct {
	Key        string `json:"key"`
	Protection string `json:"protection"`
	KeyID      string `json:"key_id,omitempty"`
}

CredentialState is how one stored credential is protected. It never carries the value.

type DeviceResult

type DeviceResult struct {
	DeviceID string         `json:"device_id"`
	Status   message.Status `json:"status"`
	Error    string         `json:"error,omitempty"`
}

DeviceResult is the outcome for one device of a push fan-out.

type EncryptReport

type EncryptReport struct {
	Providers        int `json:"providers"`
	ValuesEncrypted  int `json:"values_encrypted"`
	AlreadyEncrypted int `json:"already_encrypted"`
}

EncryptReport says what EncryptStoredCredentials changed.

type Herald

type Herald struct {
	// contains filtered or unexported fields
}

Herald is the root notification delivery engine.

func New

func New(opts ...Option) (*Herald, error)

New creates a new Herald instance with the given options.

func (*Herald) Audit

func (h *Herald) Audit(ctx context.Context, severity, outcome, action, resource, resourceID, actorID, tenant, category string, metadata map[string]string)

Audit records an audit event if a Chronicle backend is configured. This is used by both core Herald operations and the API layer.

func (*Herald) CheckRouting

func (h *Herald) CheckRouting(ctx context.Context, cfg *scope.Config) error

CheckRouting refuses a routing rule that names a provider outside cfg's app or on another channel. The error reads the same whether the ID is malformed, missing or another app's, so it never confirms that an ID exists elsewhere. Every writer of scoped configs calls it before saving.

func (*Herald) Config

func (h *Herald) Config() Config

Config returns the current configuration.

func (*Herald) CreateProvider

func (h *Herald) CreateProvider(ctx context.Context, p *provider.Provider) error

CreateProvider validates p, encrypts its credentials when a key is configured, and stores it. p is left holding what was stored.

func (*Herald) CredentialKeyID

func (h *Herald) CredentialKeyID() string

CredentialKeyID is the ID of the key new credentials are encrypted under, or "" when none is configured and credentials are stored in plaintext.

func (*Herald) CredentialStatus

func (h *Herald) CredentialStatus(p *provider.Provider) []CredentialState

CredentialStatus reports how each stored credential of p is protected.

func (*Herald) DeleteDevice

func (h *Herald) DeleteDevice(ctx context.Context, appID, id string) error

DeleteDevice removes one device, or returns ErrDeviceNotFound.

func (*Herald) DeleteProvider

func (h *Herald) DeleteProvider(ctx context.Context, appID string, providerID id.ProviderID) error

DeleteProvider removes a provider of appID.

func (*Herald) DeleteUserDevices

func (h *Herald) DeleteUserDevices(ctx context.Context, appID, userID string) error

DeleteUserDevices removes every device of a user. A user with none is not an error.

func (*Herald) Drivers

func (h *Herald) Drivers() *driver.Registry

Drivers returns the driver registry.

func (*Herald) EncryptStoredCredentials

func (h *Herald) EncryptStoredCredentials(ctx context.Context, appID string) (EncryptReport, error)

EncryptStoredCredentials encrypts every plaintext credential of every provider in appID. It is explicit and idempotent: nothing re-encrypts on read, and a second run changes nothing.

func (*Herald) GetDevice

func (h *Herald) GetDevice(ctx context.Context, appID, id string) (*device.Device, error)

GetDevice returns one device of an app, or ErrDeviceNotFound.

func (*Herald) GetProvider

func (h *Herald) GetProvider(ctx context.Context, appID string, providerID id.ProviderID) (*provider.Provider, error)

GetProvider returns a provider of appID, with credentials as stored.

func (*Herald) Health

func (h *Herald) Health(ctx context.Context) error

Health checks the health of Herald by pinging its store.

func (*Herald) ListDevices

func (h *Herald) ListDevices(ctx context.Context, appID, userID string) ([]*device.Device, error)

ListDevices returns a user's devices, oldest first.

func (*Herald) Notify

func (h *Herald) Notify(ctx context.Context, req *NotifyRequest) ([]*SendResult, error)

Notify sends a notification across multiple channels using a template, under a "notify channels" span with one "send notification" span per channel.

func (*Herald) PreviewSend

func (h *Herald) PreviewSend(ctx context.Context, req *SendRequest) (*SendPreview, error)

PreviewSend resolves the provider and sender for req exactly as Send would, through the same code, and sends nothing. It returns ErrNoProviderConfigured (wrapped) when nothing handles the channel, and Send's own errors for a chosen provider that's missing, in another app or on another channel, and ErrDriverNotFound when the provider's driver isn't registered.

func (*Herald) ResetDefaultTemplates

func (h *Herald) ResetDefaultTemplates(ctx context.Context, appID string) error

ResetDefaultTemplates deletes all system templates for an app and re-seeds the factory defaults. Custom (non-system) templates are preserved.

func (*Herald) ResolveProvider

func (h *Herald) ResolveProvider(ctx context.Context, appID, orgID, userID, channel string) (*scope.ResolveResult, error)

ResolveProvider reports which provider would send on channel for this app, org and user, and why. It returns nil, nil when nothing handles it.

func (*Herald) SaveDevice

func (h *Herald) SaveDevice(ctx context.Context, d *device.Device) error

SaveDevice registers a device, or refreshes it when the same app, user and public key (or address, for a device without a key) is already registered. It sets Channel to "push" when empty and stamps UpdatedAt and LastSeenAt. ID and CreatedAt are written back into d.

func (*Herald) SeedConfiguredProviders

func (h *Herald) SeedConfiguredProviders(ctx context.Context, providers []provider.Provider) error

SeedConfiguredProviders persists providers declared in configuration into the store, seed-if-absent: a provider is created only when no provider of the same name exists for its app. Existing records (including dashboard edits) are left untouched. A missing driver or failed Validate is logged as a warning, never an error.

func (*Herald) SeedDefaultProviders

func (h *Herald) SeedDefaultProviders(ctx context.Context, appID string) error

SeedDefaultProviders creates default providers for built-in zero-config drivers (e.g. inapp). This ensures channels work out of the box without requiring manual provider setup via the dashboard. Providers that need credentials (email, sms, push) are not seeded.

func (*Herald) SeedDefaultTemplates

func (h *Herald) SeedDefaultTemplates(ctx context.Context, appID string) error

SeedDefaultTemplates creates default notification templates for an app.

func (*Herald) Send

func (h *Herald) Send(ctx context.Context, req *SendRequest) (*SendResult, error)

Send delivers a notification on a single channel.

It records a "send notification" span. The span names the channel, template, driver and provider and counts recipients, sent and failed deliveries; it never carries a recipient, the data, the content or the metadata. Each driver's own vendor call is a client span under it.

func (*Herald) Start

func (h *Herald) Start(_ context.Context)

Start is a no-op for Herald (no background workers needed currently). This exists for interface compatibility with Forge extensions.

func (*Herald) Stop

func (h *Herald) Stop(_ context.Context)

Stop is a no-op for Herald.

func (*Herald) Store

func (h *Herald) Store() store.Store

Store returns the underlying store.

func (*Herald) UpdateProvider

func (h *Herald) UpdateProvider(ctx context.Context, appID string, providerID id.ProviderID, u ProviderUpdate) (*provider.Provider, error)

UpdateProvider applies u to a provider of appID. Credentials it sets are encrypted; credentials it doesn't touch keep their stored form exactly. It refuses, and writes nothing, when an existing credential can't be decrypted for validation, and when it moves base_url or host to a new server without setting the provider's secret credentials again (see connectionTargets).

func (*Herald) ValidateProvider

func (h *Herald) ValidateProvider(p *provider.Provider) error

ValidateProvider checks p the way Send will use it: the driver exists and handles p's channel, no secret sits in settings, no connection target (base_url, host) or schema setting sits in credentials, and the driver's own Validate accepts credentials and settings merged as Send merges them. Encrypted credentials are decrypted first. Errors never contain a value.

type NotifyRequest

type NotifyRequest struct {
	AppID    string            `json:"app_id"`
	EnvID    string            `json:"env_id,omitempty"`
	OrgID    string            `json:"org_id,omitempty"`
	UserID   string            `json:"user_id,omitempty"`
	Template string            `json:"template"`
	Locale   string            `json:"locale,omitempty"`
	To       []string          `json:"to"`
	Data     map[string]any    `json:"data,omitempty"`
	Channels []string          `json:"channels"`
	Async    bool              `json:"async,omitempty"`
	Metadata map[string]string `json:"metadata,omitempty"`
}

NotifyRequest sends a notification across multiple channels using a template.

type Option

type Option func(*Herald) error

Option configures a Herald instance.

func WithChronicle

func WithChronicle(c bridge.Chronicle) Option

WithChronicle sets the audit trail backend for the Herald instance.

func WithCredentialKey

func WithCredentialKey(keyID string, key []byte) Option

WithCredentialKey sets the AES-256 key that encrypts provider credentials written from now on. keyID is stored beside every value it encrypts.

func WithDefaultLocale

func WithDefaultLocale(locale string) Option

WithDefaultLocale sets the default locale for template rendering.

func WithDeviceStore

func WithDeviceStore(s device.Store) Option

WithDeviceStore sets the store the device methods use. Give it when the main store is wrapped in a type that hides its device methods. Without it, New uses the main store when that implements device.Store.

func WithDriver

func WithDriver(d driver.Driver) Option

WithDriver registers a notification driver with the Herald instance.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger sets the structured logger for the Herald instance.

func WithMaxBatchSize

func WithMaxBatchSize(n int) Option

WithMaxBatchSize sets the maximum number of notifications per batch send.

func WithMetrics

func WithMetrics(m gometrics.Metrics) Option

WithMetrics records herald's metrics on m: deliveries by channel, driver and result, delivery latency, suppressed recipients, rejected sends and failed message log writes. In a Forge app pass app.Metrics() (the extension does this for you) and the series scrape as forge_herald_*. A nil m records nothing, which is also the default.

func WithPreviousCredentialKey

func WithPreviousCredentialKey(keyID string, key []byte) Option

WithPreviousCredentialKey adds a key that only decrypts, so values written under a rotated-out key keep working. Give it once per old key.

func WithStore

func WithStore(s store.Store) Option

WithStore sets the persistence backend for the Herald instance.

type ProviderUpdate

type ProviderUpdate struct {
	Name              *string
	Priority          *int
	Enabled           *bool
	SetCredentials    map[string]string
	RemoveCredentials []string
	SetSettings       map[string]string
	RemoveSettings    []string
}

ProviderUpdate changes only what it names. Nil pointers and absent keys leave the stored value alone, so a caller can replace one secret without ever reading the others. A provider's channel and driver can't change.

type SendPreview

type SendPreview struct {
	Provider *provider.Provider
	Via      string
	From     string
	FromName string
}

SendPreview is what a send would use, without sending: the provider, why it was picked, and the sender it would go out as.

type SendRequest

type SendRequest struct {
	AppID    string            `json:"app_id"`
	EnvID    string            `json:"env_id,omitempty"`
	OrgID    string            `json:"org_id,omitempty"`
	UserID   string            `json:"user_id,omitempty"`
	Channel  string            `json:"channel"`
	Template string            `json:"template,omitempty"`
	Locale   string            `json:"locale,omitempty"`
	To       []string          `json:"to"`
	Data     map[string]any    `json:"data,omitempty"`
	Subject  string            `json:"subject,omitempty"`
	Body     string            `json:"body,omitempty"`
	Async    bool              `json:"async,omitempty"`
	Metadata map[string]string `json:"metadata,omitempty"`
	// ProviderID sends through this provider instead of resolving one. It must belong to AppID and handle Channel; a disabled provider is allowed, because naming it is the explicit act.
	ProviderID string `json:"provider_id,omitempty"`
	// Push carries per-message push options, copied to the driver as is.
	Push *driver.PushOptions `json:"-"`
	// DeviceFilter, when set, decides per device whether a push fan-out
	// (Channel "push", UserID set, To empty) delivers to it. Returning false
	// gives that device the status filtered.
	DeviceFilter func(*device.Device) bool `json:"-"`
}

SendRequest describes a single notification to send.

type SendResult

type SendResult struct {
	MessageID id.MessageID   `json:"message_id"`
	Status    message.Status `json:"status"`
	// ProviderID is always Herald's provider ID.
	ProviderID string `json:"provider_id,omitempty"`
	// ProviderMessageID is the vendor's ID for the message, when it gave one.
	ProviderMessageID string `json:"provider_message_id,omitempty"`
	Error             string `json:"error,omitempty"`
	// Logged is false when the message log couldn't be written. The send
	// still happened; it just won't appear in the log.
	Logged bool `json:"logged"`
	// Devices is the per-device outcome of a push fan-out. Nil otherwise.
	Devices []DeviceResult `json:"devices,omitempty"`
}

SendResult contains the outcome of a send operation.

Directories

Path Synopsis
Package api provides the Forge-integrated HTTP API for Herald.
Package api provides the Forge-integrated HTTP API for Herald.
Package bridge defines local interfaces for optional forgery extension integration.
Package bridge defines local interfaces for optional forgery extension integration.
chronicleadapter
Package chronicleadapter bridges Herald audit events to the Chronicle extension.
Package chronicleadapter bridges Herald audit events to the Chronicle extension.
Package credential encrypts provider credential values at rest.
Package credential encrypts provider credential values at rest.
Package device defines the registry of devices a user receives push notifications on, and the store every backend implements for it.
Package device defines the registry of devices a user receives push notifications on, and the store every backend implements for it.
devicetest
Package devicetest is the conformance suite every device.Store backend runs.
Package devicetest is the conformance suite every device.Store backend runs.
Package driver defines the notification driver interface and registry.
Package driver defines the notification driver interface and registry.
drivertest
Package drivertest provides shared HTTP mock-server helpers for driver unit tests.
Package drivertest provides shared HTTP mock-server helpers for driver unit tests.
email
Package email provides email notification drivers.
Package email provides email notification drivers.
inapp
Package inapp provides the in-app notification driver.
Package inapp provides the in-app notification driver.
push
Package push provides push notification drivers.
Package push provides push notification drivers.
sms
Package sms provides SMS notification drivers.
Package sms provides SMS notification drivers.
drivers
apns module
slack module
webhook module
contract
Package contract wires Herald into the Forge dashboard's contract path.
Package contract wires Herald into the Forge dashboard's contract path.
Package id defines TypeID-based identity types for all Herald entities.
Package id defines TypeID-based identity types for all Herald entities.
Package inbox defines the in-app notification entity for the inbox channel.
Package inbox defines the in-app notification entity for the inbox channel.
internal
storeerr
Package storeerr holds the not-found sentinels that packages below store need to match.
Package storeerr holds the not-found sentinels that packages below store need to match.
telemetry
Package telemetry holds what herald's spans and metrics share: the tracer, the attribute keys, the error classes and a small label vector over a go-utils metrics sink.
Package telemetry holds what herald's spans and metrics share: the tracer, the attribute keys, the error classes and a small label vector over a go-utils metrics sink.
telemetry/metricsvec
Package metricsvec declares herald's labelled counters and histograms over a go-utils metrics sink.
Package metricsvec declares herald's labelled counters and histograms over a go-utils metrics sink.
telemetry/telemetrytest
Package telemetrytest records what herald's telemetry emits, for tests: a span recorder installed as the global tracer provider, and a go-utils metrics sink that remembers every series registered on it.
Package telemetrytest records what herald's telemetry emits, for tests: a span recorder installed as the global tracer provider, and a go-utils metrics sink that remembers every series registered on it.
Package message defines the notification delivery log entity.
Package message defines the notification delivery log entity.
Package preference defines user notification preference entities.
Package preference defines user notification preference entities.
Package provider defines the notification provider entity and its store interface.
Package provider defines the notification provider entity and its store interface.
Package pushgateway is an HTTP gateway that registers devices and relays sealed pushes to FCM and APNs without ever seeing the notification content.
Package pushgateway is an HTTP gateway that registers devices and relays sealed pushes to FCM and APNs without ever seeing the notification content.
Package scope defines scoped provider configuration for app/org/user overrides.
Package scope defines scoped provider configuration for app/org/user overrides.
Package store defines the composite Store interface for all Herald persistence.
Package store defines the composite Store interface for all Herald persistence.
memory
Package memory provides an in-memory Store implementation for testing.
Package memory provides an in-memory Store implementation for testing.
mongo
Package mongo provides a MongoDB Store implementation using Grove ORM.
Package mongo provides a MongoDB Store implementation using Grove ORM.
postgres
Package postgres provides a PostgreSQL Store implementation using Grove ORM.
Package postgres provides a PostgreSQL Store implementation using Grove ORM.
sqlite
Package sqlite provides a SQLite Store implementation using Grove ORM.
Package sqlite provides a SQLite Store implementation using Grove ORM.
storetest
Package storetest is the conformance suite every herald store backend runs.
Package storetest is the conformance suite every herald store backend runs.
Package template defines the notification template entity with i18n support.
Package template defines the notification template entity with i18n support.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL