notification

package
v1.134.1 Latest Latest
Warning

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

Go to latest
Published: Sep 22, 2026 License: Apache-2.0 Imports: 10 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 (
	// ChannelKindMattermost posts through the Mattermost REST API
	// (POST /api/v4/posts) with the bot token held by the connection.
	ChannelKindMattermost = "mattermost"
	// ChannelKindWebhook posts one {"text": ...} body to an incoming-webhook
	// URL, the shape Slack and Mattermost both accept. It carries no channel
	// choice and no files: the webhook's own configuration decides where the
	// message lands.
	ChannelKindWebhook = "webhook"
	// ChannelKindEmail delivers to an operator-named recipient list through
	// the existing SMTP path. It is the one kind that names no connection,
	// because its transport is the deployment's mail server rather than an
	// upstream a persona is allowed or denied.
	ChannelKindEmail = "email"
)

Channel kinds. A kind decides what a channel needs to deliver and what a document may carry through it.

View Source
const (
	// ChannelModeImmediate delivers each document as it is enqueued.
	ChannelModeImmediate = "immediate"
	// ChannelModeDaily collects a day's documents into one bulletin
	// delivered in the deployment's digest window.
	ChannelModeDaily = "daily"
)

Channel delivery modes. The channel's mode governs; a sender does not override it, so a report that must arrive at once goes to an immediate channel rather than asking a daily one to make an exception.

View Source
const (
	// MaxChannelRecipients caps an email channel's distribution list, as
	// reviewalert.MaxRecipients caps the review alert's.
	MaxChannelRecipients = 20
	// MaxChannelNameLen bounds a channel name.
	MaxChannelNameLen = 63
	// DefaultChannelRepeatAfter is the window a repeated key is suppressed
	// for when a channel names none.
	DefaultChannelRepeatAfter = time.Hour
	// DefaultChannelMaxPerHour is the hourly send cap applied when a channel
	// names none.
	DefaultChannelMaxPerHour = 60
)

Channel bounds. They keep a typo from turning a channel into an outbound flood or an unreadable configuration.

View Source
const (
	// MaxDocumentTitleBytes bounds a document title.
	MaxDocumentTitleBytes = 512
	// MaxDocumentBytes bounds the markdown body a queue row carries.
	MaxDocumentBytes = 256 * 1024
	// MaxDocumentLinkBytes bounds the link.
	MaxDocumentLinkBytes = 2048
)

Document bounds. The body cap is what a queue row may carry; each kind's own cap, which is smaller, decides how much of it a reader sees.

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"
	// 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"
	// CategoryConnectionAuth covers the alert raised when an upstream rejects a
	// connection's refresh and the platform discards the credential (#1694).
	// Like the two above it carries no per-user toggle, for the script-run
	// reason: its first recipient is the person who authorized the connection,
	// which is a responsibility and not an interest, and its second is whoever
	// the operator named to hear about a connection nobody has come back to.
	// ModeOff, including through the unsubscribe link, remains each
	// recipient's own opt-out.
	//
	// This is the one auth surface where the person holding the upstream
	// credential is deliberately not the person using the connection day to
	// day, which is exactly why that person will not be watching the status
	// card that already reports it.
	CategoryConnectionAuth = "connection_auth"
	// CategoryChannel covers a document sent to an operator-configured
	// channel (#1720): a script's monitor post, a published report, a
	// message a person asked the agent to send. Like the three above it
	// carries no per-user toggle, and for a third reason: its recipient is
	// usually not a person at all but a chat channel, and where it IS a
	// person — an address on an email channel's list — the operator named
	// them, so removing the address is the way to stop sending. ModeOff,
	// including through the unsubscribe link, remains that person's own
	// opt-out.
	CategoryChannel = "channel"
)

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"
	// 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"
	// KindConnectionAuth marks a connection whose credential was discarded
	// (#1694). Its payload carries a ConnectionAuth describing the revocation
	// instead of an item reference.
	KindConnectionAuth = "connection_auth"
	// KindChannel marks a document addressed to a channel (#1720). Its
	// payload carries a Document instead of an item reference, and the row's
	// Channel names the destination.
	KindChannel = "channel"
)

Payload item kinds.

View Source
const ChannelRecipientPrefix = "channel:"

ChannelRecipientPrefix marks a queue row addressed to a channel rather than to a person. The worker dispatches on it, and the enqueue path skips the preference lookup for it: a channel has no preferences, and nothing about it is a mailbox that could unsubscribe.

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 ChannelName added in v1.134.0

func ChannelName(recipient string) (string, bool)

ChannelName returns the channel a queue row was addressed to, and whether the row was addressed to one at all. The worker dispatches on it.

func ChannelNeedsConnection added in v1.134.0

func ChannelNeedsConnection(kind string) bool

ChannelNeedsConnection reports whether a kind delivers through an api connection. It is the one statement of that split, read by validation, by the admin form and by the persona filter that authorizes a send.

func Deliverable added in v1.121.0

func Deliverable(addr string) bool

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

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.

func ValidateChannel added in v1.134.0

func ValidateChannel(c Channel) error

ValidateChannel reports what is wrong with a channel record, in the words an administrator reads in the settings form.

The kind-specific rules are refusals rather than silent normalizations because each one names a field the operator filled in that their chosen kind cannot use: a webhook carries no channel target, an email channel reaches no api connection, and a slack channel with no target would post nowhere.

func ValidateDocument added in v1.134.0

func ValidateDocument(d Document) error

ValidateDocument bounds what may be enqueued for a channel. The body cap is applied here, at enqueue, so an oversized document is refused to its sender rather than discovered by the worker when nobody is listening.

Types

type Channel added in v1.134.0

type Channel struct {
	// Name identifies the channel and is its primary key.
	Name string `json:"name"`
	// Kind is one of the ChannelKind* constants.
	Kind string `json:"kind"`
	// Description is the operator's sentence about what this channel is for.
	Description string `json:"description,omitempty"`
	// Enabled gates delivery. A disabled channel refuses an enqueue rather
	// than silently collecting rows nobody will send.
	Enabled bool `json:"enabled"`
	// Connection names the api connection the three HTTP kinds deliver
	// through. Empty for the email kind.
	Connection string `json:"connection,omitempty"`
	// Target is the chat channel id a post is addressed to: Slack's C… id or
	// Mattermost's channel id. Empty for the webhook and email kinds.
	Target string `json:"target,omitempty"`
	// Recipients is the email kind's distribution list. Empty for the three
	// HTTP kinds.
	Recipients []string `json:"recipients,omitempty"`
	// Mode is ChannelModeImmediate or ChannelModeDaily.
	Mode string `json:"mode"`
	// RepeatAfter is how long a repeated key is suppressed for. Zero means
	// DefaultChannelRepeatAfter.
	RepeatAfter time.Duration `json:"repeat_after,omitempty"`
	// MaxPerHour bounds the channel's outbound rate across replicas. Zero
	// means DefaultChannelMaxPerHour.
	MaxPerHour int `json:"max_per_hour,omitempty"`
	// CreatedBy is the administrator who created the channel.
	CreatedBy string `json:"created_by,omitempty"`
	// UpdatedAt is when the record was last written.
	UpdatedAt time.Time `json:"updated_at,omitzero"`
}

Channel is an operator-configured destination: what it is called, how it delivers, and what its kind needs to deliver. It holds no credential of its own — the three HTTP kinds name an api connection, whose credential is encrypted at rest and whose persona authorization is the channel's.

func (Channel) HourlyCap added in v1.134.0

func (c Channel) HourlyCap() int

HourlyCap returns the channel's hourly send cap, applying the default for an unset one.

func (Channel) Recipient added in v1.134.0

func (c Channel) Recipient() string

Recipient returns the queue-row recipient for a channel: the channel form for the three HTTP kinds, which are addressed as a destination rather than as a person. An email channel has no single recipient — it fans out to one row per address — so this is not the form its rows carry.

func (Channel) RepeatWindow added in v1.134.0

func (c Channel) RepeatWindow() time.Duration

RepeatWindow returns the channel's suppression window, applying the default for an unset one.

type ChannelStore added in v1.134.0

type ChannelStore interface {
	// List returns every channel in name order.
	List(ctx context.Context) ([]Channel, error)
	// Get returns one channel by name, or ErrChannelNotFound.
	Get(ctx context.Context, name string) (*Channel, error)
	// Set creates or replaces a channel.
	Set(ctx context.Context, ch Channel) error
	// Delete removes a channel. Deleting an absent channel is not an error.
	Delete(ctx context.Context, name string) error
}

ChannelStore persists the operator's channel records. internal/notification/notifychannel holds the PostgreSQL implementation.

type ConnectionAuth added in v1.131.2

type ConnectionAuth struct {
	// Kind is the connection kind (mcp, api, graphql). With Name it is how a
	// recipient finds the connection, and the two are rendered together rather
	// than as one joined string so the email can link to it.
	Kind string `json:"kind"`
	// Name is the connection name within the kind.
	Name string `json:"name"`
	// IDPHost is the host of the upstream token endpoint that rejected the
	// refresh. Empty when the platform decided locally (see Reason).
	IDPHost string `json:"idp_host,omitempty"`
	// Reason is what the upstream returned, in the stable short form the auth
	// event history records: an RFC 6749 error code such as invalid_grant or
	// invalid_client when the upstream answered, and no_refresh_token or
	// refresh_expired when the platform reached the verdict without calling
	// it. The email states which of those two it was, because they ask the
	// recipient for different things.
	Reason string `json:"reason,omitempty"`
	// Description is the upstream's error_description, bounded by the
	// platform. Carried for a refused jwt_bearer assertion, where it is
	// usually the only statement of which upstream approval is missing.
	Description string `json:"description,omitempty"`
	// SignedAssertion marks a refused jwt_bearer assertion rather than a
	// revoked authorization (#1734). The email then says there is nothing to
	// reconnect and sends the reader to the upstream: the key, the integration
	// user or the clocks, and it says the alert clears itself when an exchange
	// is next accepted.
	SignedAssertion bool `json:"signed_assertion,omitempty"`
	// AuthorizedBy is the identity that authorized the connection, and the
	// address the first alert is sent to. It is carried in the payload as well
	// so the escalation to the operator's chosen recipients can say whose
	// authorization lapsed.
	AuthorizedBy string `json:"authorized_by,omitempty"`
	// RevokedAt is when the credential was discarded.
	RevokedAt time.Time `json:"revoked_at,omitzero"`
	// Escalated marks the second alert: the connection was still unauthorized
	// after the operator's escalation window, so it went to the addresses the
	// operator named rather than to the person who authorized it. The two
	// alerts carry the same revocation and differ only in who is being asked
	// to act, which is what this field lets the email say.
	Escalated bool `json:"escalated,omitempty"`
	// EscalatedAfterHours is the window that elapsed before the escalation was
	// raised. Zero on the first alert.
	EscalatedAfterHours int `json:"escalated_after_hours,omitempty"`
}

ConnectionAuth is the revocation a KindConnectionAuth notification reports: which connection lost its credential, which upstream rejected it, what the upstream said, and when.

The queued row holds the revocation as the platform saw it rather than a sentence about it, for the ReviewQueue reason: a digest recipient reads what actually happened, not a re-measurement taken when the digest went out. Here there is a second reason — by the time the mail is rendered the token row has been deleted, so nothing could be re-read even if the renderer wanted to.

type Document added in v1.134.0

type Document struct {
	// Title is the message's subject line: the email subject, the Slack
	// header block, the Mattermost first-line heading.
	Title string `json:"title"`
	// Body is markdown. What a reader sees of it depends on the kind.
	Body string `json:"body,omitempty"`
	// Link is the absolute URL a reader follows to the asset, the run, or
	// whatever else produced the message.
	Link string `json:"link,omitempty"`
}

Document is what a channel carries: a title, a markdown body, and a link back to whatever produced it. Every kind renders these three as far as it can and falls back to an excerpt plus the link past its own cap, so a long report always arrives as a summary and a pointer.

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) NotifyChannel added in v1.134.0

func (e *Enqueuer) NotifyChannel(ctx context.Context, ch Channel, actor string, doc Document) (int, error)

NotifyChannel queues doc for delivery to ch and reports how many rows were written.

The three HTTP kinds write one row addressed to the channel. The email kind writes one row per recipient instead of one row for the list, so a person on an operator's list keeps everything a person has: their own ModeOff, their own digest window, and the unsubscribe link in the footer. That is the same choice the review-queue alert makes for its operator-named recipients, and for the same reason -- a distribution list is not a mailbox, and treating it as one would make one person's opt-out silence everyone.

The actor's rate limit is charged once for the whole send, not once per recipient: the size of an email channel's list is the operator's choice, not the sender's, so a script posting to a twenty-address channel must not exhaust a budget that exists to bound the addresses a sender picks.

A zero return with a nil error means the send was accepted and nothing was queued -- every recipient on an email channel had opted out, or the actor was over their rate limit. A caller reporting to a person should say so rather than reporting a send.

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"`
	// Channel names the destination a KindChannel row was sent to, and is
	// empty for every row addressed to a person alone. It is a column rather
	// than a payload field because the worker claims on it (a chat row is
	// deliverable with no mail server configured) and the admin history
	// filters on it.
	Channel      string     `json:"channel,omitempty"`
	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"`
	// Connection carries the revocation a KindConnectionAuth alert reports and
	// is nil for every other kind.
	Connection *ConnectionAuth `json:"connection,omitempty"`
	// Document carries what a KindChannel row delivers and is nil for every
	// other kind. It holds the message rather than a reference to one
	// because a channel document has no platform record behind it to re-read
	// at send time: the sender composed it, and what was composed is what
	// must arrive.
	Document *Document `json:"document,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 deliverable under
	// filter, returning ErrNoWork when none is due.
	ClaimImmediate(ctx context.Context, lease time.Duration, filter TransportFilter) (*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, filter TransportFilter) ([]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.

type TransportFilter added in v1.134.0

type TransportFilter struct {
	// Email includes every row delivered over SMTP: rows addressed to a
	// person, whether or not an email channel put them there.
	Email bool
	// Channel includes rows addressed to a channel destination, delivered
	// over the channel kind's HTTP transport.
	Channel bool
}

TransportFilter narrows a claim to the rows the worker can currently deliver. It exists because the two transports fail independently: a deployment that has configured channels but no mail server delivers to chat and leaves its email rows pending, and a deployment whose api gateway is still wiring delivers email and leaves its chat rows pending.

Before channels the worker asked one question -- is SMTP usable? -- and answered no by draining nothing. That answer is wrong once a row can be deliverable by another route, so the question moved into the claim.

func (TransportFilter) Deliverable added in v1.134.0

func (f TransportFilter) Deliverable() bool

Deliverable reports whether the filter admits any row at all. A worker whose filter admits nothing skips the claim rather than issuing a query that cannot match.

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