Help us shape the future of Go! Take the 2026 Go Developer Survey

notif

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 10, 2026 License: AGPL-3.0 Imports: 23 Imported by: 0

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

View Source
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.

View Source
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).

View Source
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

View Source
var (
	ErrNotFound       = errors.New("notif: notification not found")
	ErrUnauthorized   = errors.New("notif: not your notification")
	ErrUnsubscribeBad = errors.New("notif: invalid unsubscribe token")
)

Errors surfaced by the orchestrator.

View Source
var Skip = Action{}

Skip is the zero-action used when the matrix decides the event-relation pair shouldn't notify.

Functions

func EmailPrefKey

func EmailPrefKey(reason Reason) string

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

func Emit(ctx context.Context, pool *pgxpool.Pool, ev Event) error

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

func EmitTx(ctx context.Context, tx pgx.Tx, ev Event) error

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

func FanoutOnce(ctx context.Context, deps Deps) (int, error)

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

func HMACSig(key []byte, payload string) string

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

func ResolveMentionUserIDs(ctx context.Context, pool *pgxpool.Pool, ms []markdown.Mention) []int64

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.

func VerifyUnsubscribe

func VerifyUnsubscribe(key []byte, recipientID int64, threadKind string, threadID int64, sig string) bool

VerifyUnsubscribe checks the HMAC. Returns true when the signature matches the supplied (recipient, thread) tuple.

Types

type Action

type Action struct {
	NotifyInbox    bool
	EmailDefault   bool
	OverrideIgnore bool
	Reason         Reason
}

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

func Routing(kind string, rel Relation) Action

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"
)

type Relation

type Relation int

Relation is how a recipient relates to an event. The routing matrix is keyed on (event-kind, relation). Typically more than one relation applies — the fan-out worker picks the highest- priority one (the order matches the priority).

const (
	RelMention Relation = iota
	RelAssignee
	RelReviewer
	RelAuthor
	RelCommenter
	RelSubscribedThread
	RelWatchingAll
	RelWatchingParticipating
	RelRepoOwner // for repo-admin lifecycle events
)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL