Vault

Preferences

Manage per-user notification preferences with per-type per-channel opt-in/opt-out controls.

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 Entity

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.

ChannelPreference

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:

ValueMeaning
nilNo preference set -- use default (opted in)
trueExplicitly opted in
falseExplicitly opted out

Per-Type Per-Channel Control

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 Check

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 all
  • Returns false if no override exists for the notification type
  • Returns false if the channel field is nil (no preference set)
  • Returns true only when the channel field is explicitly set to false

Default Behavior

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 email
  • The only way to prevent delivery is to set a channel to false

Upsert Semantics

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 Interface

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 Delivery

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 Endpoints

Preferences are managed through the /preferences route group:

MethodPathOperation
GET/preferencesGet preferences (query: app_id, user_id)
PUT/preferencesUpdate 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 UI

A typical preferences UI shows a matrix of notification types and channels. For each cell, display a toggle based on the ChannelPreference value:

Notification TypeEmailSMSPushIn-App
Welcomeon----on
Password Changedonoff--on
Weekly Digestoff--off--

Cells with -- represent nil values (no explicit preference, defaults to on). This allows you to only show relevant channels per notification type.

On this page