Documentation
¶
Overview ¶
Package notify is a small unified-notifications primitive for GoFastr apps. Each notification is identified by a type ("order.shipped", "password.reset", etc.) and a Recipient; the Notifier renders a per-channel template and fans the rendered message out across every channel the routing function selects.
Channels are bundled separately so apps can wire whichever subset they need (email, in-app, webhook, push) without pulling in unrelated dependencies. A LoggerChannel ships in the package for development; the email channel adapter lives in this same package because battery/email is already a framework dependency.
Wiring:
tmpl := notify.NewMapTemplater()
tmpl.Set("order.shipped", "email", notify.Template{
Subject: "Your order has shipped",
TextBody: "Hi {{name}}, your order #{{id}} is on its way.",
})
n := notify.New(
notify.WithTemplater(tmpl),
notify.WithChannel(notify.NewLoggerChannel(log.Default())),
)
err := n.Send(ctx, notify.Notification{
Type: "order.shipped",
To: notify.Recipient{UserID: "u1", Email: "alice@example.com"},
Data: map[string]any{"name": "Alice", "id": 42},
})
Index ¶
Constants ¶
const MaxInterpolatedOutputBytes = 1 << 20
MaxInterpolatedOutputBytes is the hard cap on the output of a single [interpolate] call. A 10 MB placeholder value would otherwise produce an unbounded subject / body string and pin a goroutine in fmt.Fprint. 1 MiB is well above any legitimate notification payload.
Variables ¶
var ( ErrNoTemplater = errors.New("notify: no templater installed") ErrNoChannels = errors.New("notify: no channels selected for notification") )
Errors returned by Send.
var ErrUnsafeHeader = errors.New("notify: unsafe header")
ErrUnsafeHeader is returned when rendered Extra contains a header name/value that would let a caller smuggle SMTP headers (CR/LF), or when it tries to override a reserved header that the channel owns.
var ErrUnsafeRecipient = errors.New("notify: unsafe recipient address")
ErrUnsafeRecipient is returned by EmailChannel.Send when the recipient address contains characters that would let a caller smuggle extra headers (CR / LF / NUL) or that fails a sanity-check on shape (must contain "@" and no embedded HTML / control bytes).
Functions ¶
func DefaultRouter ¶
DefaultRouter selects channels by Recipient field presence:
- "email" when To.Email != ""
- "sms" when To.Phone != ""
- "webhook" when To.Webhook != ""
- "push" when len(To.PushTokens) > 0
- "log" / "inapp" always
Channels with names not in this list are skipped — register a custom router via WithRouter to support more.
Types ¶
type Channel ¶
type Channel interface {
Name() string
Send(ctx context.Context, n Notification, r Rendered) error
}
Channel is the send-side adapter. Implementations must be safe for concurrent use; the Notifier may invoke Send from many goroutines.
type EmailChannel ¶
type EmailChannel struct {
// contains filtered or unexported fields
}
EmailChannel adapts a battery/email Sender to the notify Channel interface. Renders Subject/TextBody/HTMLBody from the Templater into the email's matching fields.
func NewEmailChannel ¶
func NewEmailChannel(sender email.Sender, from string, opts ...EmailChannelOption) *EmailChannel
NewEmailChannel wraps a battery/email Sender with a from-address default. Pass any [EmailChannelOption]s to tweak the registration.
func (*EmailChannel) Send ¶
func (c *EmailChannel) Send(ctx context.Context, n Notification, r Rendered) error
Send implements Channel. Maps the Rendered payload onto an email.Email and dispatches via the wrapped Sender.
Recipient and from addresses are checked for the three shapes that turn an address into a header-injection vector: CR, LF, NUL. They are also screened for an "@" and rejected if they contain HTML tag characters — a literal "<script>alert(1)</script>@evil.com" is never a legitimate address. The downstream SMTP sender re-checks at the transport boundary, but rejecting early surfaces the bug nearer the data source.
type EmailChannelOption ¶
type EmailChannelOption func(*EmailChannel)
EmailChannelOption configures the email adapter.
func WithEmailChannelName ¶
func WithEmailChannelName(name string) EmailChannelOption
WithEmailChannelName overrides the registered name (default "email"). Useful when you want multiple email adapters (e.g. transactional vs marketing) on the same Notifier.
type LoggerChannel ¶
type LoggerChannel struct {
// contains filtered or unexported fields
}
LoggerChannel writes notifications to a *log.Logger — useful for development and CI. Always applies (DefaultRouter routes "log" or "inapp" unconditionally).
func NewLoggerChannel ¶
func NewLoggerChannel(l *log.Logger) *LoggerChannel
NewLoggerChannel constructs a logger-backed channel. Pass nil to use the default logger; pass "" name for "log".
func (*LoggerChannel) Send ¶
func (c *LoggerChannel) Send(_ context.Context, n Notification, r Rendered) error
Send writes a one-line record to the logger.
type MapTemplater ¶
type MapTemplater struct {
// contains filtered or unexported fields
}
MapTemplater is the simplest Templater: a (notifType, channel) → Template lookup table. Suitable for apps with a small fixed set of notifications; for catalog-driven i18n use [I18nTemplater].
func NewMapTemplater ¶
func NewMapTemplater() *MapTemplater
NewMapTemplater returns an empty MapTemplater.
func (*MapTemplater) Render ¶
func (m *MapTemplater) Render(_ context.Context, notifType, channel string, data map[string]any) (Rendered, error)
Render implements Templater.
The rendered Subject is stripped of CR / LF / NUL so a user-controlled {{placeholder}} can't inject header continuations when downstream transports (SMTP, push providers) treat Subject as a header value. TextBody / HTMLBody are not modified — those are payload bytes and any HTML safety is the rendering layer's job.
func (*MapTemplater) Set ¶
func (m *MapTemplater) Set(notifType, channel string, t Template)
Set registers a template for the (notifType, channel) pair.
type Notification ¶
Notification is one event-shaped message bound for a single Recipient. The Notifier picks channels by inspecting the Recipient (e.g. only sends to "email" channel when To.Email is non-empty), renders a per-channel template against Data, and fans out.
type Notifier ¶
type Notifier struct {
// contains filtered or unexported fields
}
Notifier is the front door. Construct with New, register channels via WithChannel, and call [Send] from handlers.
func (*Notifier) Send ¶
func (n *Notifier) Send(ctx context.Context, msg Notification) error
Send routes the notification, renders it per channel, and fires each channel concurrently. Returns the first error encountered (or nil); use WithErrorCallback to observe per-channel failures.
Notifications with no selected channel return ErrNoChannels. Notifications without a templater (and no pre-rendered payload in Data["_rendered"]) return ErrNoTemplater.
type Option ¶
type Option func(*Notifier)
Option configures the Notifier.
func WithChannel ¶
WithChannel registers a Channel. Multiple calls accumulate; channel names must be unique (a second registration replaces the first).
func WithErrorCallback ¶
func WithErrorCallback(fn func(channel string, n Notification, err error)) Option
WithErrorCallback installs a callback for per-channel send errors. The Notifier still attempts every selected channel; the callback surfaces failures so they can be logged/metricized without bringing the call site down.
func WithRouter ¶
WithRouter installs a custom router. The default router uses DefaultRouter which selects channels by Recipient field presence.
func WithTemplater ¶
WithTemplater installs a Templater. If unset, Notifier.Send returns ErrNoTemplater for any notification that doesn't carry a pre-rendered payload in Data["_rendered"].
type Recipient ¶
type Recipient struct {
UserID string
Email string
Phone string
Webhook string
// PushTokens is the optional list of device tokens for push
// channels — kept as a slice rather than a single field because
// users frequently have multiple devices.
PushTokens []string
}
Recipient is the destination set. Channels are responsible for reading the fields they care about — UserID is canonical, the rest are channel-specific addresses.
type Rendered ¶
type Rendered struct {
Subject string
TextBody string
HTMLBody string
// Extra carries channel-specific extras the templater wants the
// channel to see — e.g. an attachment list, a SMS short-link, a
// webhook payload override.
Extra map[string]any
}
Rendered is the per-channel materialised message.
type Router ¶
Router decides which channels a Notification should target. Default router: every registered channel that "applies" to the Recipient. Channels declare their applicability by name; for built-ins:
- "email" applies when Recipient.Email != ""
- "sms" applies when Recipient.Phone != ""
- "webhook" applies when Recipient.Webhook != ""
- "push" applies when len(Recipient.PushTokens) > 0
- "log" / "inapp" always apply
type Template ¶
Template is one per-channel template. The fields are interpolated with the same `{{placeholder}}` form used by the i18n package.
type Templater ¶
type Templater interface {
Render(ctx context.Context, notifType, channel string, data map[string]any) (Rendered, error)
}
Templater renders a Notification + channel name into a Rendered payload. Implementations should be deterministic over the (notifType, channel, data) inputs — caching is the caller's choice.