Notifications
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.
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/gpsystem/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)
}
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.
Channels
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.
Database
import notifydatabase "github.com/gp-system/gpsystem/notify/database"
store := notifydatabase.NewStore(pg.NewDB(pool)) // pgx
store := notifydatabasebunx.NewStore(bunDB) // bun, via notify/database/bunx
channel := notifydatabase.NewChannel(store)
Persists Databasable notifications into a notifications table:
CREATE TABLE notifications (
id uuid PRIMARY KEY,
recipient_id text NOT NULL,
name text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
No notifiable_type column (the kit has no polymorphic user model, a recipient is just a string id), no updated_at (rows only ever gain read_at). The channel requires Recipient.ID; an on-demand send (no ID) errors on this channel: name only notify.ChannelMail in Via for those.
The Store interface covers the universal 90%: Insert, List (ListOptions{UnreadOnly, Limit, Offset}), CountUnread, MarkRead, MarkAllRead. Every read/update takes recipientID explicitly, so a handler built on top can never touch another recipient's rows by accident. Building the actual HTTP inbox (response DTOs, the rbac.Identity.Subject → recipientID guard) is your module's own territory: the kit stops at the Store, the same boundary the outbox draws around its own table.
There is no NOTIFY_* env config: channel enablement is the wiring; the Hub contains exactly the channels the entry point constructs.
Broadcast
See Realtime for the full channel: it delivers Broadcastable (or, failing that, Databasable) notifications to the recipient's own live connection through the realtime gateway, and only wires in once gpsystem add realtime has run.
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.
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.