Notifications

> Send notifications across mail, database, broadcast, and Slack from a single definition.

The notification package lets you define a single notification and deliver it over any combination of channels - mail, database, broadcast (WebSocket), Slack. You write ToMail, ToDatabase, ToBroadcast, ToSlack as needed; the manager dispatches to the right channel driver.

Import path: github.com/velocitykode/velocity/notification

Core interfaces

type Notification interface {
    Via(notifiable any) []string  // channel names, e.g. "mail", "database"
}

type Notifiable interface {
    NotificationRoute(channel string) string  // address for this channel
}

type Channel interface {
    Send(ctx context.Context, notifiable any, notification Notification) error
}

A notification declares which channels it targets. A notifiable (usually your User model) tells each channel where to deliver. A channel performs the actual delivery.

Defining a notification

type OrderShipped struct {
    OrderID  int
    Carrier  string
    TrackURL string
}

func (n *OrderShipped) Via(_ any) []string {
    return []string{"mail", "database"}
}

func (n *OrderShipped) ToMail(_ any) *notification.MailMessage {
    return notification.NewMailMessage().
        Subject("Your order has shipped").
        Greeting("Hi there!").
        Line("Your order is on its way.").
        Action("Track your package", n.TrackURL).
        Line(fmt.Sprintf("Carrier: %s", n.Carrier)).
        Outro("Thanks for shopping with us.")
}

func (n *OrderShipped) ToDatabase(_ any) *notification.DatabaseMessage {
    return notification.NewDatabaseMessage("orders.shipped").
        Set("order_id", n.OrderID).
        Set("carrier", n.Carrier).
        Set("track_url", n.TrackURL)
}

Defining a notifiable

A notifiable is anything that implements Notifiable.NotificationRoute(channel string) string. The channel driver calls this once per send to learn where to deliver. Each built-in channel interprets the returned string differently:

ChannelWhat NotificationRoute should returnUsed by
"mail"RFC 5322 email addressMailChannel falls back to this when MailMessage.To() is empty
"database"Stable identifier (string) for the row’s notifiable_id columnDatabaseChannel writes this verbatim into notifications.notifiable_id
"broadcast"A user identifier; the channel prefixes it with private- (e.g. 42 becomes private-42) when BroadcastMessage.On(...) is emptyBroadcastChannel
"slack"A full Slack incoming-webhook URLSlackChannel POSTs the rendered payload here; URLs that resolve to private/internal addresses are rejected
customWhatever your channel needs (push token, phone number, etc.)Your channel implementation

Return "" for any channel the notifiable does not support; the channel will surface “no route” as a delivery error rather than silently dropping the send.

type User struct {
    ID              int
    Email           string
    SlackWebhookURL string
}

func (u *User) NotificationRoute(channel string) string {
    switch channel {
    case "mail":
        return u.Email
    case "database":
        return strconv.Itoa(u.ID)
    case "broadcast":
        return strconv.Itoa(u.ID)
    case "slack":
        return u.SlackWebhookURL
    }
    return ""
}

The same notifiable can serve every channel; Via() decides which routes are actually consulted on a given send.

Sending

The manager fans out to each channel in Via():

mgr := notification.NewManager()

err := mgr.Send(ctx, user, &OrderShipped{
    OrderID:  42,
    Carrier:  "UPS",
    TrackURL: "https://ups.com/track/42",
})

Send to many recipients at once:

mgr.SendMany(ctx, []any{user1, user2, user3}, notif)

SendMany accumulates errors - one failed recipient doesn’t prevent the others from receiving the notification.

Recipe: Send the same notification on mail, slack, and database

When: A single domain event (a new signup, a paid invoice, a shipped order) needs to land in the user’s inbox, post to a team Slack channel, and persist to the in-app inbox at the same time, without writing the dispatch logic three times.

Code:

type SignupReceived struct {
    User *User
}

func (n *SignupReceived) Via(_ any) []string {
    return []string{"mail", "slack", "database"}
}

func (n *SignupReceived) ToMail(_ any) *notification.MailMessage {
    return notification.NewMailMessage().
        Subject("Welcome to Acme").
        Greeting("Hi " + n.User.Name).
        Line("Thanks for signing up.").
        Action("Open dashboard", "https://acme.app/dashboard")
}

func (n *SignupReceived) ToSlack(_ any) *notification.SlackMessage {
    return notification.NewSlackMessage().
        Content("New signup: " + n.User.Email).
        AsUser("Signups Bot").
        WithIcon(":wave:")
}

func (n *SignupReceived) ToDatabase(_ any) *notification.DatabaseMessage {
    return notification.NewDatabaseMessage("signup.received").
        Set("user_id", n.User.ID).
        Set("email", n.User.Email)
}

// elsewhere
mgr.Send(ctx, n.User, &SignupReceived{User: n.User})

Why this shape: Via() is the routing decision and lives on the notification, not the notifiable, because the same User can receive different notifications over different channel sets. The manager iterates Via() in order and calls each channel’s Send; one channel failing returns an error but does not short-circuit the remaining channels, so a flaky Slack webhook will not block the database row from being written. Each To* method must match the channel listed in Via(), since channels assert the notification implements their channel-specific interface (e.g. SlackChannel requires SlackNotification) and return an error otherwise. Routes for each channel come from the notifiable’s NotificationRoute(channel); for the recipe above, User.NotificationRoute("slack") must return a webhook URL and NotificationRoute("database") must return a stable id.

See also: mail, broadcast, events (listen to notification.sent / notification.failed).

Channels

The package ships four built-in channels. Blank-import allchannels to register all of them at once via their init() side effects:

import _ "github.com/velocitykode/velocity/notification/allchannels"

This pulls in mail, database, broadcast, and slack.

Or cherry-pick by importing only the channel packages you want. Each channel’s init() calls RegisterChannel exactly once, so importing notification/channels is enough; calling RegisterChannel for an already-registered name panics.

import _ "github.com/velocitykode/velocity/notification/channels"

After registration, mgr.Channel("mail") lazily instantiates the driver and returns it; the manager uses this internally when a notification lists "mail" in Via(). Use mgr.SetChannel(name, ch) to inject a pre-configured channel instance (e.g. a *MailChannel with SetMailer(...) already called).

Mail channel

Notifications implementing MailNotification (have a ToMail method) build a *MailMessage and send it through the mail package.

Message builder covers the common shape - greeting, body lines, call-to-action button, outro - plus full HTML/text body override when you need custom templates:

return notification.NewMailMessage().
    From("no-reply@example.com", "Example").
    To(user.Email).
    Subject("Welcome").
    Greeting("Hi "+user.Name).
    Line("Thanks for signing up.").
    Action("Get started", "https://example.com/onboard").
    Outro("Need help? Reply to this email.")

For complete control:

return notification.NewMailMessage().
    To(user.Email).
    Subject("Receipt").
    HTMLBody(renderedHTML).
    TextBody(plainText).
    AttachData(pdfBytes, "receipt.pdf", "application/pdf")

Database channel

Stores the notification as a row in a notifications table (or whichever table your store implements). Use this for in-app notification inboxes.

return notification.NewDatabaseMessage("billing.invoice.paid").
    Set("invoice_id", inv.ID).
    Set("amount", inv.Total)

The Type field is a logical name; Data is serialized as JSON.

Broadcast channel

Delivers to real-time WebSocket subscribers via the broadcast package. The notifiable’s route should be a channel name ("private-user.42", "orders").

return notification.NewBroadcastMessage("order.shipped").
    On("private-user."+strconv.Itoa(n.UserID)).
    Set("order_id", n.OrderID).
    Set("track_url", n.TrackURL)

Slack channel

Posts to a Slack webhook URL returned by the notifiable:

return notification.NewSlackMessage().
    Content("New signup: "+user.Email).
    AsUser("Signups Bot").
    WithIcon(":wave:").
    Attachment(func(a *notification.SlackAttachment) {
        a.Color = "good"
        a.Title = user.Name
        a.Field("Plan", user.Plan, true)
        a.Field("Trial ends", user.TrialEnd.Format("2006-01-02"), true)
    })

Events

Every send emits one of two events:

  • *notification.NotificationSent - on successful delivery
  • *notification.NotificationFailed - on delivery error

Wire them up via the manager:

mgr.SetEventDispatcher(func(event any) error {
    return v.Events.Dispatch(event)
})

Useful for delivery metrics, retry workers, or audit logs.

Custom channels

Implement the Channel interface and register it:

type PushChannel struct { /* APNs/FCM client */ }

func (c *PushChannel) Send(ctx context.Context, notifiable any, n notification.Notification) error {
    // 1. Assert n implements your channel-specific interface (e.g. PushNotification)
    // 2. Use notifiable.(notification.Notifiable).NotificationRoute("push") for the token
    // 3. Send it
    return nil
}

notification.RegisterChannel("push", func() (notification.Channel, error) {
    return &PushChannel{/* ... */}, nil
})

From then on, any notification listing "push" in Via() goes through your channel.

  • Events - emit a domain event and let a listener fan out the notification
  • Queue - dispatch notifications through queue jobs so request handlers stay snappy
  • Mail - the underlying transport for the email channel