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
- Variables
- func NextDigestTime(now time.Time, hourUTC int) time.Time
- func NormalizeAddress(s string) string
- func PortalLink(baseURL, route string) string
- func RecipientsExcluding(actor string, candidates ...string) []string
- func Snippet(s string) string
- func ValidMode(m string) bool
- type Enqueuer
- type HistoryFilter
- type HistoryStore
- type Notification
- type Payload
- type Prefs
- type PrefsStore
- type PrefsUpdate
- type QueueStore
- type ReviewQueue
Constants ¶
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.
const ( 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.
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.
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.
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 ¶
var ErrNoWork = errors.New("notification: no work available")
ErrNoWork is returned by QueueStore claim methods when no due row is available.
Functions ¶
func NextDigestTime ¶
NextDigestTime returns the next occurrence of hourUTC:00 strictly after now. The result is in UTC.
func NormalizeAddress ¶ added in v1.117.0
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 ¶
PortalLink builds an absolute portal SPA link, or empty when no public base URL is configured.
func RecipientsExcluding ¶
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.
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"`
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 ¶
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"`
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.
Source Files
¶
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. |