Herald's preference system lets users control which notifications they receive and on which channels. Preferences are checked automatically during the delivery pipeline -- if a user has opted out, the notification is silently skipped.
Preference Entity01
The preference.Preference struct stores a user's notification preferences for an application:
type Preference struct {
ID id.PreferenceID `json:"id"`
AppID string `json:"app_id"`
UserID string `json:"user_id"`
Overrides map[string]ChannelPreference `json:"overrides"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}Preference IDs use the hprf prefix.
ChannelPreference02
The ChannelPreference struct holds per-channel opt-in/opt-out flags for a specific notification type:
type ChannelPreference struct {
Email *bool `json:"email,omitempty"`
SMS *bool `json:"sms,omitempty"`
Push *bool `json:"push,omitempty"`
InApp *bool `json:"inapp,omitempty"`
}Each field is a *bool (pointer to bool) to distinguish three states:
| Value | Meaning |
nil | No preference set -- use default (opted in) |
true | Explicitly opted in |
false | Explicitly opted out |
Per-Type Per-Channel Control03
The Overrides map is keyed by notification type slug (the template slug). This gives users granular control over each notification type on each channel:
pref := &preference.Preference{
AppID: "app_01abc...",
UserID: "user_01xyz...",
Overrides: map[string]preference.ChannelPreference{
"auth.welcome": {
Email: boolPtr(true), // receive welcome emails
InApp: boolPtr(true), // receive welcome in-app
},
"auth.password-changed": {
Email: boolPtr(true), // receive password change emails
SMS: boolPtr(false), // do NOT receive password change SMS
},
"marketing.weekly-digest": {
Email: boolPtr(false), // opt out of marketing emails
Push: boolPtr(false), // opt out of marketing push
},
},
}
func boolPtr(b bool) *bool { return &b }Opt-Out Check04
The IsOptedOut method checks whether a user has explicitly opted out of a specific notification type on a specific channel:
func (p *Preference) IsOptedOut(notifType string, channel string) bool {
if p == nil || p.Overrides == nil {
return false
}
cp, ok := p.Overrides[notifType]
if !ok {
return false
}
switch channel {
case "email":
return cp.Email != nil && !*cp.Email
case "sms":
return cp.SMS != nil && !*cp.SMS
case "push":
return cp.Push != nil && !*cp.Push
case "inapp":
return cp.InApp != nil && !*cp.InApp
default:
return false
}
}Key behaviors:
Returns
false(not opted out) if no preference exists at allReturns
falseif no override exists for the notification typeReturns
falseif the channel field isnil(no preference set)Returns
trueonly when the channel field is explicitly set tofalse
Default Behavior05
When no preference is set, Herald always delivers the notification. The preference system is opt-out only -- users must explicitly disable channels they do not want to receive.
This means:
A brand-new user with no preferences receives all notifications on all channels
Setting
Email: boolPtr(true)is functionally equivalent to having no preference for emailThe only way to prevent delivery is to set a channel to
false
Upsert Semantics06
The store uses upsert semantics for SetPreference. If a preference already exists for the (app_id, user_id) pair, it is replaced:
err := store.SetPreference(ctx, pref)This simplifies the API -- callers always provide the full preference state, and the store handles insert-or-update.
Store Interface07
type Store interface {
GetPreference(ctx context.Context, appID string, userID string) (*Preference, error)
SetPreference(ctx context.Context, p *Preference) error
DeletePreference(ctx context.Context, appID string, userID string) error
}Integration with Delivery08
During Send, Herald checks preferences before proceeding with template rendering and provider resolution:
// In Herald.Send()
if req.UserID != "" && req.Template != "" {
pref, _ := h.store.GetPreference(ctx, req.AppID, req.UserID)
if pref != nil && pref.IsOptedOut(req.Template, channel) {
h.logger.Debug("herald: user opted out",
"user_id", req.UserID,
"template", req.Template,
"channel", channel,
)
return &SendResult{Status: message.StatusSent, Error: "user opted out"}, nil
}
}Note that both UserID and Template must be present for the preference check to run. Direct sends without a template slug bypass preference checking.
API Endpoints09
Preferences are managed through the /preferences route group:
| Method | Path | Operation |
GET | /preferences | Get preferences (query: app_id, user_id) |
PUT | /preferences | Update preferences (upsert) |
Update Preferences Request
{
"app_id": "app_01abc...",
"user_id": "user_01xyz...",
"overrides": {
"auth.welcome": {
"email": true,
"inapp": true
},
"marketing.weekly-digest": {
"email": false,
"push": false
}
}
}Get Preferences Response
{
"id": "hprf_01j8x7k2...",
"app_id": "app_01abc...",
"user_id": "user_01xyz...",
"overrides": {
"auth.welcome": {
"email": true,
"inapp": true
},
"marketing.weekly-digest": {
"email": false,
"push": false
}
},
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-20T14:00:00Z"
}Building a Preferences UI10
A typical preferences UI shows a matrix of notification types and channels. For each cell, display a toggle based on the ChannelPreference value:
| Notification Type | SMS | Push | In-App | |
| Welcome | on | -- | -- | on |
| Password Changed | on | off | -- | on |
| Weekly Digest | off | -- | off | -- |
Cells with -- represent nil values (no explicit preference, defaults to on). This allows you to only show relevant channels per notification type.