Overview
A notification is content addressed to a recipient (a user, not just an e-mail address) that can go out on more than one channel: persisted in a database inbox, sent by mail, pushed live over the realtime gateway. Which channels fire depends entirely on what the notification itself declares.
notify is a standalone Go module (github.com/gp-system/notify, +notify/database, +notify/broadcast). It builds on mail for the mail channel and on dbx for the database channel, but deliberately does not depend on queue: broadcast is fire-and-forget pub/sub, and the durable database channel is a plain insert, so nothing here needs durable job queueing. Asynchronous delivery, when you want it, comes from dispatching an event and calling Hub.Send from a listener, the same way mail does.
Install
go get github.com/gp-system/notify@v0.1.0 # the kit itself uses this tag
go get github.com/gp-system/notify@latest
| Import path | What it adds |
|---|---|
github.com/gp-system/notify | the Hub, Notification/Recipient, the mail channel |
github.com/gp-system/notify/database (+/bunx) | the durable database channel, see Database |
github.com/gp-system/notify/broadcast | the live-push channel and gateway building blocks, see Broadcast |
The third deliberate abstraction
Alongside storage and mail, notify is the kit's third conscious exception to "centralize, don't abstract": a first-party system meant to make building products faster, not a wrapper hiding a single third-party API. It stays thin: one type assertion per channel per send, zero reflection.
The core types
import "github.com/gp-system/notify"
type Notification interface {
NotificationName() string
Via(ctx context.Context, r Recipient) []string
}
type Recipient struct {
ID string // stable recipient id (an rbac.Identity.Subject in kit-issued JWTs)
Email string
Name string
}
NotificationName is a stable, dotted identifier ("contact.admin_notification"): the database channel persists it, spans and logs carry it, the same convention as events.Event.EventName. Via is evaluated per send, so the same notification type can route differently for different recipients, e.g. database-only when the recipient has no e-mail on file.
Per-channel content is a separate, optional interface the channel type-asserts for:
type Mailable interface {
ToMail(ctx context.Context, r Recipient) (*mail.Message, error)
}
type Databasable interface {
ToDatabase(ctx context.Context, r Recipient) (any, error)
}
There's a third, Broadcastable, covered on the Broadcast page alongside the channel that consumes it. If Via names a channel the notification does not implement the content interface for, that channel returns notify.ErrNoContent; attempted regardless (see Hub.Send below), never silently skipped.
The Hub
hub := notify.NewHub(
notify.NewMailChannel(mailer),
notifydatabase.NewChannel(store),
)
err := hub.Send(ctx, notify.Recipient{ID: "u1", Email: "user@example.com"}, OrderShipped{...})
NewHub panics on a duplicate channel name: a boot-time wiring mistake, not a runtime condition. Send:
- Stamps a delivery ID into
ctx(notify.IDFromContext): the database channel uses it as the row's primary key, the broadcast channel's envelope carries the same ID, so a client can reconcile a live push against a database row without refetching. - Attempts every channel
Vianames, in order, synchronously, in the caller's goroutine. - A channel name with no registered
Channelis skipped silently (discard semantics, the same "not configured" storysmtp.NewIfConfiguredalready has). - Every named channel is attempted regardless of an earlier failure; errors are joined (
errors.Join), each wrapped with the channel and notification name.
There is no built-in queued mode: notify follows the same rule mail does. Dispatch an event and call Hub.Send from a listener for asynchronous delivery, with the existing at-least-once retry and idempotency guarantees. A listener redelivery can double-insert a database notification; that is an accepted, documented caveat, the same one events already carry.
The mail channel
notify.NewMailChannel(mailer) // mailer: any mail.Mailer (SMTP, discard, memory)
Delegates to the same kit mail.Mailer add mail wires: a project's mail transport is shared between direct mail.Mailer sends and notifications. If ToMail's returned *mail.Message has no To set, the channel routes it to r.Email/r.Name automatically.
add mail and add notification are independent and compose: use add mail for content sent straight to an address with no recipient concept (a contact-form confirmation); use add notification for content addressed to a recipient, possibly on several channels. A notification's ToMail reuses the same mail.Message/mail.Template builders a mail notifier's Notify does.
For the database channel (notify/database), see the Database page; for the broadcast channel (notify/broadcast), see Broadcast.
add notification
gpsystem add notification <module> <name>
Generates internal/modules/<module>/notifications/<name>.go: an empty payload struct, Via defaulting to [database, mail], a ToMail building on the kit's mail.Message/mail.Template, a ToDatabase returning the notification itself, plus its templates/<name>.{html,txt}.tmpl. On the project's first notification, wires a shared notify.Sender (mail + database channels, plus broadcast once the realtime gateway is present) into every entry point and adds the notifications table migration. See the add notification reference for the exact generated shape and the wiring anchors.
Testing
mem := notify.NewMemory(notify.ChannelDatabase) // captures Deliveries() for any channel name
hub := notify.NewHub(mem)
For content-rendering coverage, prefer the real channels over real drivers: notify.NewMailChannel(mail.NewMemory()) and notifydatabase.NewChannel(notifydatabase.NewMemoryStore()): MemoryStore implements the full Store (including the read side), so a product can test its inbox handlers without Postgres. See Database and Broadcast for the per-channel test doubles.
Worked example: the contact-email flow
The mail page's worked example sends a contact-form submission's admin notification and requester confirmation as two direct mails. Recast as a notification, the admin side becomes a notify.Notification addressed to the admin account (subject "admin" in a single-admin project), delivered on both the database channel (a persisted admin inbox) and the mail channel (the same address the direct send used); while the requester confirmation, which has no recipient concept beyond the form's e-mail address, stays a direct mail.Mailer send:
package notifications
type AdminNotification struct {
contactmail.ContactNotification // the same payload the requester e-mail renders
}
func (AdminNotification) NotificationName() string { return "contact.admin_notification" }
func (AdminNotification) Via(context.Context, notify.Recipient) []string {
return []string{notify.ChannelDatabase, notify.ChannelMail}
}
func (n AdminNotification) ToMail(context.Context, notify.Recipient) (*kitmail.Message, error) {
return kitmail.NewMessage().
WithSubject(adminSubjectLine(n.ContactNotification)).
WithBody(kitmail.Template(templatesFS, "templates/adminnotification.html.tmpl", n).
WithTextTemplate("templates/adminnotification.txt.tmpl")), nil
}
func (n AdminNotification) ToDatabase(context.Context, notify.Recipient) (any, error) { return n, nil }
func sendContactEmail(deps contact.Dependencies) func(context.Context, contactevents.ContactSubmitted) error {
requesterNotifier := contactmail.New(deps.Mailer)
return func(ctx context.Context, ev contactevents.ContactSubmitted) error {
payload := contactmail.ContactNotification{Name: ev.Name, Email: ev.Email /* ... */}
adminErr := deps.Notifier.Send(ctx,
notify.Recipient{ID: "admin", Email: deps.Mail.AdminEmail},
contactnotifications.AdminNotification{ContactNotification: payload},
)
requesterErr := requesterNotifier.Notify(ctx, payload)
return errors.Join(adminErr, requesterErr)
}
}
AdminNotification embeds the same ContactNotification payload the requester e-mail renders, so both surfaces stay in sync from one source of truth. The admin now gets a persisted, queryable notification history for free, with no change to the requester-facing side at all.