Documentation
¶
Overview ¶
Package notif owns S29's notification fan-out: consume from `domain_events`, compute the recipient set per the routing matrix, materialize inbox rows + email-side dispatch.
The recipient computation is the only interesting code here. The routing matrix is data (a Go map keyed on event-kind + recipient-relation), the auto-subscription rules are a tiny set of "if absent, insert", and the storm dampener is a per-recipient, per-thread, time-window count.
Visibility re-check at fan-out is the security boundary: a repo can flip from public to private between event-emit and fan-out; a stale-read public event must not produce a notification for a recipient who can no longer see the underlying resource.
Index ¶
- Constants
- Variables
- func EmailPrefKey(reason Reason) string
- func Emit(ctx context.Context, pool *pgxpool.Pool, ev Event) error
- func EmitTx(ctx context.Context, tx pgx.Tx, ev Event) error
- func FanoutOnce(ctx context.Context, deps Deps) (int, error)
- func HMACSig(key []byte, payload string) string
- func ResolveMentionUserIDs(ctx context.Context, pool *pgxpool.Pool, ms []markdown.Mention) []int64
- func VerifyUnsubscribe(key []byte, recipientID int64, threadKind string, threadID int64, sig string) bool
- type Action
- type Deps
- type Event
- type Reason
- type Relation
Constants ¶
const ( StormPerThreadCap = 1 StormPerThreadMins = 10 StormAbsoluteCap = 100 StormAbsoluteMins = 60 )
StormDampener defaults — one email per recipient per thread per 10 minutes (the spec's day-1 lean). Per-recipient absolute cap is 100 emails per hour.
const FanoutBatch = 200
FanoutBatch is the per-tick cap on how many domain_events the fan-out worker drains in one call. Bounded so a single tick can't monopolize the worker pool when a backlog accumulates (e.g. after a deploy that takes the worker offline for a moment).
const FanoutConsumer = "notify_fanout"
FanoutConsumer is the consumer name used in `domain_events_processed`. Other consumers (S33 webhooks) will use distinct names so cursors don't collide.
Variables ¶
var ( ErrNotFound = errors.New("notif: notification not found") ErrUnsubscribeBad = errors.New("notif: invalid unsubscribe token") )
Errors surfaced by the orchestrator.
var Skip = Action{}
Skip is the zero-action used when the matrix decides the event-relation pair shouldn't notify.
Functions ¶
func EmailPrefKey ¶
EmailPrefKey returns the user_notification_prefs key for the per-kind email toggle. Empty means "no toggle exists for this reason — fall back to global default true."
func Emit ¶
Emit inserts one row into domain_events and wakes the worker pool. Use within a tx via EmitTx when the emit must atomically commit with the source change (the typical case — issue.Create + the matching event row land together or not at all).
The emit side is intentionally thin: the routing matrix and recipient computation live entirely in the fan-out worker so callers don't grow a per-kind switch.
func EmitTx ¶
EmitTx is the in-tx variant. Use it when the source mutation and the event row must commit together (almost always). The NOTIFY is deferred to commit by Postgres semantics; this matches the rest of the worker's enqueue patterns.
func FanoutOnce ¶
FanoutOnce drains up to FanoutBatch domain_events starting after the persisted cursor, computes recipient sets, and writes inbox rows + (optionally) emails. Returns the number of events processed so the caller can decide to loop.
Designed to be invoked from a worker job (see `internal/worker/jobs/notify_fanout.go`); the cron framework's landing date will determine how it's scheduled (the job kind is already in the worker registry).
func HMACSig ¶
HMACSig signs a payload with the unsubscribe HMAC key. Exposed so tests can build verifying signatures without re-implementing the canonical wire format.
func ResolveMentionUserIDs ¶
ResolveMentionUserIDs maps a slice of (possibly duplicate) `markdown.Mention` records to deduplicated user IDs. Unknown, suspended, and soft-deleted accounts are silently dropped — the fan-out worker should never produce notifications for accounts that wouldn't have read access. Result is stable-ordered by first-occurrence in the input.
The lookup is one-by-one rather than batched: typical mention counts are in the single digits and the per-username GetUserByUsername is index-backed. Batching becomes worth it only above ~50 distinct mentions per body.
Types ¶
type Action ¶
Action is the routing-matrix output. NotifyInbox controls the in-app inbox row; EmailDefault is whether to email by default (subject to the user's per-kind email pref). OverrideIgnore means the event notifies even when the user has set `watches.level = 'ignore'` for the repo (matches GitHub's "@mentions always notify" semantics).
func Routing ¶
Routing returns the action for an (event-kind, recipient relation) pair. The matrix is small enough today to be a switch; once we have ~30 event kinds it'll graduate to a real table-driven shape.
Unknown kinds → Skip (deny by default — better to miss a notification than to spam users when a new kind is added but not routed).
type Deps ¶
type Deps struct {
Pool *pgxpool.Pool
Logger *slog.Logger
EmailSender email.Sender
EmailFrom string
SiteName string
BaseURL string
UnsubscribeKey []byte
}
Deps wires the package against the rest of the runtime.
EmailSender is optional: when nil, notifications still land in the inbox but no email goes out. Useful in tests + during local dev when the operator hasn't configured SMTP. EmailFrom is the default From address; per-template overrides are post-MVP.
SiteName is the human-readable site name used in subject lines / list-unsubscribe footers ("shithub" by default; operators can re-brand for self-hosted instances).
BaseURL is the public-facing scheme+host (e.g. "https://shithub.example") used to build canonical links in email bodies + the unsubscribe URL.
UnsubscribeKey is the HMAC-SHA256 key for the one-click list-unsubscribe URL. Rotating the key invalidates outstanding unsubscribe links — operators are expected to keep it stable.
type Event ¶
type Event struct {
ActorUserID int64 // 0 → system event (NULL in DB).
Kind string // e.g. "issue_comment_created", "pr_review_requested".
RepoID int64 // 0 → user-scoped event (NULL).
SourceKind string // "issue", "pull", "repo", "user", …
SourceID int64
Public bool // matches repo visibility (caller decides).
Mentions []int64 // resolved user-ids to fan out @-mentions to.
Extra map[string]any
}
Event is the call-site shape for an emitter. The notif package owns the schema-side details (which columns are NULL, payload shape, JSON marshal); callers fill in the semantic fields.
Mentions, when present, are surfaced to the fan-out worker via the `mentions` JSON key in payload — kept here so callers don't have to remember the wire convention.
type Reason ¶
type Reason string
Reason is the short string the inbox row + email footer surface to the user ("you were mentioned", "you were assigned", etc.). Kept short because it's also the `notification_email_log.notification_id`-joined column on the inbox row.
const ( ReasonMention Reason = "mention" ReasonAssignment Reason = "assignment" ReasonReviewRequested Reason = "review_requested" ReasonAuthor Reason = "author" // you opened the thread ReasonCommenter Reason = "commenter" // you commented earlier ReasonSubscribed Reason = "subscribed" // explicit thread sub ReasonWatching Reason = "watching" // repo-level watch ReasonRepoAdminAction Reason = "repo_admin_action" )