notification

package
v1.118.0 Latest Latest
Warning

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

Go to latest
Published: Aug 1, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package notification is the domain of the platform's email notifications: the event model, the preference model, the two store contracts that persist them, and the enqueue path share and thread-comment triggers call.

It is the vocabulary every other layer of the substrate is written in, and depends on none of them:

pkg/notification/smtp                     admin-configured mail server settings
internal/notification/notifyprefs         preference persistence
internal/notification/notifyqueue         queue persistence + LISTEN wakeup
internal/notification/notifyrender        branded email rendering
internal/notification/notifysend          SMTP transport
internal/notification/notifyworker        the send worker that drains the queue
internal/httpserver/notifyhttp            self-scoped preference REST
internal/httpserver/unsubhttp             no-login unsubscribe endpoint

internal/platform/notifydelivery assembles them into one startable handle.

Index

Constants

View Source
const (
	// DefaultHistoryLimit is the page size a listing uses when none is asked
	// for.
	DefaultHistoryLimit = 50
	// MaxHistoryLimit caps a caller-supplied page size.
	MaxHistoryLimit = 200
)

History listing bounds.

View Source
const (
	// CategoryShare covers direct shares of assets, collections, and prompts.
	CategoryShare = "share"
	// CategoryComment covers thread comments and feedback events.
	CategoryComment = "comment"
	// CategoryMention covers being named in a comment with an @-mention
	// (#627). It is separate from CategoryComment so muting general thread
	// chatter still leaves a person reachable when someone addresses them
	// directly.
	CategoryMention = "mention"
	// CategoryReviewQueue covers the operator alert raised when the knowledge
	// review queue crosses its staleness threshold (#803). Unlike the three
	// above it carries no per-user toggle: the operator names its recipients
	// in the admin settings, so removing an address there is the way to stop
	// sending it. A recipient still opts out for themselves with ModeOff,
	// including through the no-login unsubscribe link every email carries.
	CategoryReviewQueue = "review_queue"
)

Notification categories. A category maps to a per-user preference toggle and an email template family.

View Source
const (
	// ModeOff drops notifications at enqueue time.
	ModeOff = "off"
	// ModeImmediate queues one email per event.
	ModeImmediate = "immediate"
	// ModeDaily batches a user's events into one digest email per day.
	ModeDaily = "daily"
)

Delivery modes for user preferences.

View Source
const (
	// StatusPending marks a row waiting to be claimed.
	StatusPending = "pending"
	// StatusSending marks a row claimed by a worker (lease via locked_until).
	StatusSending = "sending"
	// StatusSent marks a delivered row.
	StatusSent = "sent"
	// StatusFailed marks a row that exhausted its attempts.
	StatusFailed = "failed"
)

Queue row statuses.

View Source
const (
	// KindAsset marks a shared asset.
	KindAsset = "asset"
	// KindCollection marks a shared collection.
	KindCollection = "collection"
	// KindPrompt marks a shared prompt.
	KindPrompt = "prompt"
	// KindComment marks a thread comment.
	KindComment = "comment"
	// KindFeedback marks a thread feedback event.
	KindFeedback = "feedback"
	// KindMention marks a comment that named the recipient.
	KindMention = "mention"
	// KindReviewQueue marks a knowledge review-queue staleness alert (#803).
	// Its payload carries a Review rollup instead of an item reference.
	KindReviewQueue = "review_queue"
)

Payload item kinds.

Variables

View Source
var ErrNoWork = errors.New("notification: no work available")

ErrNoWork is returned by QueueStore claim methods when no due row is available.

Functions

func NextDigestTime

func NextDigestTime(now time.Time, hourUTC int) time.Time

NextDigestTime returns the next occurrence of hourUTC:00 strictly after now. The result is in UTC.

func NormalizeAddress added in v1.117.0

func NormalizeAddress(s string) string

NormalizeAddress reduces an email address to its comparison and storage form: the bare address, lowercased, with any display name stripped. "Display Name <User@Example.com>" and " user@example.com " both yield "user@example.com". A value that does not parse as an address falls back to trimmed-and-lowercased, so a malformed address still compares equal to itself.

Every address that reaches the queue passes through here, so the self-notification check, recipient de-duplication, and the preference lookup all agree on which strings name the same person regardless of the shape each store happens to hold.

func PortalLink(baseURL, route string) string

PortalLink builds an absolute portal SPA link, or empty when no public base URL is configured.

func RecipientsExcluding

func RecipientsExcluding(actor string, candidates ...string) []string

RecipientsExcluding returns the de-duplicated candidate list minus the actor and empties, in NormalizeAddress form. Used to fan a thread event out to the target owner and thread author without self-notification.

Both sides are normalized before comparison, so an owner or grantee stored as "Display Name <addr>" is still recognized as the actor, and the same person recorded in two address shapes yields one recipient rather than two.

func Snippet

func Snippet(s string) string

Snippet bounds a message body for an email excerpt without splitting a multi-byte rune.

func ValidMode

func ValidMode(m string) bool

ValidMode reports whether m is one of the delivery modes.

Types

type Enqueuer

type Enqueuer struct {
	// contains filtered or unexported fields
}

Enqueuer is the trigger-side entry point: it consults the recipient's preferences and either drops the event, queues it for immediate delivery, or schedules it into the recipient's next daily digest window.

Enqueue is a single cheap DB insert; callers on a request path must log a returned error and continue — a share or comment never fails because its notification could not be queued.

func NewEnqueuer

func NewEnqueuer(prefs PrefsStore, queue QueueStore, digestHourUTC int) *Enqueuer

NewEnqueuer creates an Enqueuer. digestHourUTC is the hour of day (0-23, UTC) daily digests are scheduled for. Close releases the limiter's background goroutine.

func (*Enqueuer) Close

func (e *Enqueuer) Close()

Close stops the limiter's background eviction goroutine. Nil-safe.

func (*Enqueuer) Notify

func (e *Enqueuer) Notify(ctx context.Context, recipient, category string, p Payload) (queued bool, err error)

Notify queues one notification for recipient according to their preferences and reports whether a row was written. Events targeting nobody (empty recipient) or the actor themselves are dropped silently, as are events the recipient opted out of and events over the actor's rate limit; all of those return queued=false with a nil error. A nil Enqueuer (feature not wired, e.g. no database) drops everything.

Callers that fan one event out across several categories must branch on queued rather than on the error: a recipient who was dropped here has been told nothing, so the caller may still owe them a different notification.

func (*Enqueuer) NotifyFanout added in v1.114.0

func (e *Enqueuer) NotifyFanout(ctx context.Context, recipients []string, category string, p Payload) []string

NotifyFanout queues p for every recipient of an audience the actor did not choose -- the people a target is already shared with -- and returns the recipients a row was written for.

It charges the actor's rate limit once for the whole fan-out rather than once per recipient: the size of this audience is a property of the item, not something the actor picked, so a comment on a widely-shared asset must not exhaust the budget that bounds the addresses they DO pick (shares and mentions). The recipient count is bounded instead by maxFanout, and a truncated fan-out is logged with both counts rather than silently trimmed.

type HistoryFilter added in v1.118.0

type HistoryFilter struct {
	// Recipient scopes the listing to one address. Empty means every
	// recipient, which only an admin-gated caller may ask for.
	Recipient string
	// Status is one of the Status* constants. Empty means any status.
	Status string
	// Category is one of the Category* constants. Empty means any category.
	Category string
	// Limit bounds the page; zero or negative means DefaultHistoryLimit.
	// Values above MaxHistoryLimit are clamped.
	Limit int
	// Offset is the page start.
	Offset int
}

HistoryFilter narrows a notification history listing. A zero value lists everything, newest first.

Recipient is matched exactly against the stored address, which is always NormalizeAddress form, so a caller scoping a listing to one person must normalize before filtering. The self-scoped user view depends on that: its whole authorization is this one field.

func (HistoryFilter) EffectiveLimit added in v1.118.0

func (f HistoryFilter) EffectiveLimit() int

EffectiveLimit resolves the page size the store will apply.

type HistoryStore added in v1.118.0

type HistoryStore interface {
	// List returns one page of notifications, newest first.
	List(ctx context.Context, filter HistoryFilter) ([]Notification, error)
	// Count returns how many rows match the filter, ignoring its paging
	// fields.
	Count(ctx context.Context, filter HistoryFilter) (int, error)
	// CountsByStatus returns the per-status row counts for the filter,
	// keyed by the Status* constants. It honors every field of the filter,
	// so a caller wanting the whole breakdown of a status-filtered view
	// clears Status first.
	CountsByStatus(ctx context.Context, filter HistoryFilter) (map[string]int, error)
}

HistoryStore reads the delivery history the queue leaves behind: what was sent, what failed and why, and what is still waiting.

It is a separate contract from QueueStore because it is a separate concern with a separate audience. QueueStore is the worker's write path and must stay small; this is the read path two UI surfaces sit on -- the admin monitoring tab and each user's own activity screen.

What it can show is bounded by the worker's retention pass: resolved rows are purged after notifyworker.DefaultResolvedRetention, so this is recent history, not an archive. Both surfaces state that window to the reader.

type Notification

type Notification struct {
	ID           int64      `json:"id"`
	Recipient    string     `json:"recipient"`
	Category     string     `json:"category"`
	Payload      Payload    `json:"payload"`
	Digest       bool       `json:"digest"`
	Status       string     `json:"status"`
	Attempts     int        `json:"attempts"`
	LastError    string     `json:"last_error,omitempty"`
	ScheduledFor time.Time  `json:"scheduled_for"`
	SentAt       *time.Time `json:"sent_at,omitempty"`
	CreatedAt    time.Time  `json:"created_at"`
}

Notification is one queued delivery.

type Payload

type Payload struct {
	// Kind is one of the Kind* constants.
	Kind string `json:"kind"`
	// ItemID identifies the shared or commented item.
	ItemID string `json:"item_id"`
	// ItemTitle is the human-readable name of the item.
	ItemTitle string `json:"item_title"`
	// Actor is the email of the person who shared or commented.
	Actor string `json:"actor"`
	// Message is an optional comment/feedback snippet.
	Message string `json:"message,omitempty"`
	// Link is the absolute portal deep link for the item.
	Link string `json:"link,omitempty"`
	// Review carries the review-queue rollup of a KindReviewQueue alert and
	// is nil for every other kind.
	Review *ReviewQueue `json:"review,omitempty"`
}

Payload carries the event details a template needs to render an email. It is stored as the queue row's JSONB payload.

type Prefs

type Prefs struct {
	Email           string    `json:"email"`
	Mode            string    `json:"mode"`
	SharesEnabled   bool      `json:"shares_enabled"`
	CommentsEnabled bool      `json:"comments_enabled"`
	MentionsEnabled bool      `json:"mentions_enabled"`
	UpdatedAt       time.Time `json:"updated_at"`
}

Prefs is one user's notification preferences. Absence of a stored row means DefaultPrefs applies (immediate delivery, all categories on), per the platform's important-features-default-on convention.

func DefaultPrefs

func DefaultPrefs(email string) Prefs

DefaultPrefs returns the preferences applied to a user with no stored row.

type PrefsStore

type PrefsStore interface {
	// Get returns the user's preferences, falling back to DefaultPrefs when
	// no row exists. It never returns an error for an unknown user.
	Get(ctx context.Context, email string) (Prefs, error)
	// Set upserts the user's preferences, applying u over the current values.
	Set(ctx context.Context, email string, u PrefsUpdate) (Prefs, error)
}

PrefsStore persists per-user notification preferences.

type PrefsUpdate

type PrefsUpdate struct {
	Mode            *string `json:"mode,omitempty"`
	SharesEnabled   *bool   `json:"shares_enabled,omitempty"`
	CommentsEnabled *bool   `json:"comments_enabled,omitempty"`
	MentionsEnabled *bool   `json:"mentions_enabled,omitempty"`
}

PrefsUpdate carries the fields of a preferences write; nil fields keep the current (or default) value.

func (PrefsUpdate) Apply added in v1.118.0

func (u PrefsUpdate) Apply(p *Prefs)

Apply overlays the update's set fields onto p, leaving the rest untouched. Which fields a partial write may leave alone is a property of the preference model rather than of any one backend, so every store applies an update the same way by calling this.

type QueueStore

type QueueStore interface {
	// Enqueue inserts a pending row and nudges the send worker.
	Enqueue(ctx context.Context, n Notification) error
	// ClaimImmediate claims the next due non-digest row under a lease,
	// returning ErrNoWork when none is due.
	ClaimImmediate(ctx context.Context, lease time.Duration) (*Notification, error)
	// ClaimDigest claims every due digest row for one recipient under a
	// lease, returning ErrNoWork when none is due.
	ClaimDigest(ctx context.Context, lease time.Duration) ([]Notification, error)
	// MarkSent transitions claimed rows to sent.
	MarkSent(ctx context.Context, ids []int64) error
	// Retry returns claimed rows to pending after backoff, recording the error.
	Retry(ctx context.Context, ids []int64, sendErr string, backoff time.Duration) error
	// Fail marks claimed rows permanently failed, recording the error.
	Fail(ctx context.Context, ids []int64, sendErr string) error
	// PurgeOld bounds table growth: it deletes resolved (sent/failed) rows
	// older than resolvedRetention and unresolved rows older than
	// pendingTTL. The latter also guarantees that enabling SMTP on a
	// deployment that queued for months does not deliver an ancient
	// backlog. Returns the number of rows deleted.
	PurgeOld(ctx context.Context, resolvedRetention, pendingTTL time.Duration) (int64, error)
}

QueueStore persists and claims queued notifications. The enqueue path writes through it; a send worker claims from it under a lease and resolves what it claimed. internal/notification/notifyqueue holds the PostgreSQL implementation.

type ReviewQueue added in v1.118.0

type ReviewQueue struct {
	// Pending is the total number of insights awaiting review.
	Pending int `json:"pending"`
	// OldestAgeDays is the age in days of the oldest pending insight.
	OldestAgeDays int `json:"oldest_age_days"`
	// StaleCount is how many pending insights are at least StaleAfterDays
	// old -- the accumulating review debt.
	StaleCount int `json:"stale_count"`
	// StaleAfterDays is the age at which a pending insight counts toward
	// StaleCount. The email states it rather than assuming the reader knows
	// the platform's staleness window.
	StaleAfterDays int `json:"stale_after_days"`
}

ReviewQueue is the pending-review rollup a KindReviewQueue notification carries. The renderer turns it into the alert's subject and body, so the queued row holds the numbers rather than a sentence about them.

The values are the queue as the check saw it. A daily-digest recipient therefore reads what actually tripped the threshold, not a re-measurement taken when the digest happened to go out.

Directories

Path Synopsis
Package smtp is the admin-configured mail server layer of the notification substrate: the stored connection settings, the admin API's write/read shapes with their validation, and the store that persists them with the password encrypted at rest.
Package smtp is the admin-configured mail server layer of the notification substrate: the stored connection settings, the admin API's write/read shapes with their validation, and the store that persists them with the password encrypted at rest.

Jump to

Keyboard shortcuts

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