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