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 Deliverable(addr string) bool
- 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" // CategoryScriptRun covers the alert raised when a SCHEDULED managed-script // run fails (#1286). Like the review-queue alert it carries no per-user // toggle, and for a stronger reason: its recipient is the person // accountable for the automation — the script's owner — so it is addressed // to a responsibility rather than to an interest. ModeOff, including // through the unsubscribe link, remains the recipient's own opt-out. // // A run somebody asked for through run_script never comes here: that // failure is reported to the caller in the tool's own response, and mailing // it as well would notify a person about something they are already // reading. CategoryScriptRun = "script_run" )
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" // KindScriptRun marks a failed scheduled script run (#1286). Its payload // names the script in ItemTitle, the run in ItemID, and carries the failure // and the tail of what the script printed in Message. KindScriptRun = "script_run" )
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 Deliverable ¶ added in v1.121.0
Deliverable reports whether an address is one the platform should ever try to send mail to (#1345).
It is not a deliverability oracle and makes no claim about addresses in general: it recognizes the one domain the platform mints for itself. An API key with no configured email authenticates as name@apikey.local, which is an identity rather than a mailbox. That address becomes an asset's owner_email when an agent saves an asset under the key, and the owner is an unconditional recipient of feedback on their asset, so without this check every comment on an agent-produced asset queues a message no MX will ever accept -- five retry attempts, then a failed row that buries the genuine failures in the admin delivery history.
An API key configured with a real email address is a real mailbox and is deliverable like anyone else.
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. |