notification

package
v1.145.0 Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: Apache-2.0 Imports: 11 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 (
	// ChannelKindSlack posts through the Slack Web API (chat.postMessage,
	// and the external upload sequence for files) with the bot token held by
	// the connection.
	ChannelKindSlack = "slack"
	// 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 (
	// ChannelFormatText posts {"text": ...}, the incoming-webhook shape Slack
	// and Mattermost accept. The default, so a channel configured before
	// formats existed delivers as it did.
	ChannelFormatText = "text"
	// ChannelFormatJSON posts one envelope per notification for a system
	// to receive: an id stable across retries, a type to route on, the
	// source, and the sender's structured data.
	ChannelFormatJSON = "json"
)

Webhook channel payload formats (#1997). Only the webhook kind has a choice: the chat kinds post in their API's shape and email renders a message.

View Source
const (
	// DocumentScriptFinding is a managed script's platform.notify.
	DocumentScriptFinding = "script.finding"
	// DocumentScriptPublished is a managed script's platform.publish.
	DocumentScriptPublished = "script.published"
	// DocumentNotifySent is a person's notify action=send.
	DocumentNotifySent = "notify.sent"
	// DocumentNotifyPublished is a person's notify action=publish.
	DocumentNotifyPublished = "notify.published"
	// DocumentTest is an administrator's test send.
	DocumentTest = "test"
)

Document types: what a channel document is, which a JSON envelope carries as its type for a receiver to route on (#1997).

View Source
const (
	DocumentSourceScript = "script"
	DocumentSourceUser   = "user"
	DocumentSourceAdmin  = "admin"
)

Document source kinds.

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
	// MaxDocumentKeyBytes bounds a suppression key.
	MaxDocumentKeyBytes = 256
	// MaxDocumentDataBytes bounds the structured data a document carries
	// to a JSON webhook channel.
	MaxDocumentDataBytes = 64 * 1024
	// MaxAttachments bounds the files one document carries: five, the
	// Mattermost per-post limit and the lowest of the kinds.
	MaxAttachments = 5
	// MaxAttachmentBytes bounds one attachment's content. A file over it
	// reaches the reader as a link instead.
	MaxAttachmentBytes = 8 << 20
)

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 ErrChannelOverCap = errors.New("notification: channel is over its hourly cap")

ErrChannelOverCap is returned when a channel has already been sent its hourly cap. Nothing is queued and the history records nothing; the sender is told, in the error, which cap it met.

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 ChannelCarriesFiles added in v1.143.0

func ChannelCarriesFiles(kind string) bool

ChannelCarriesFiles reports whether a kind delivers attachments as files. The others name each attachment by its link.

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 Attachment added in v1.143.0

type Attachment struct {
	// AssetID names a portal asset.
	AssetID string `json:"asset_id,omitempty"`
	// ResourceID names a managed resource.
	ResourceID string `json:"resource_id,omitempty"`
	// Version is the asset version the attachment was made from, recorded
	// at enqueue so a later edit of the asset does not change what a
	// queued message sends. Zero for a resource, which has no versions.
	Version int `json:"version,omitempty"`
	// Preview, with AssetID, sends the asset's light tile image instead of
	// its content: what a chat reader sees of a report before opening it.
	Preview bool `json:"preview,omitempty"`
	// Name is the file name the reader sees.
	Name string `json:"name,omitempty"`
	// Link is where a reader opens the asset or resource, used in place of
	// the file by a kind that carries none or when the file is over the
	// attachment cap.
	Link string `json:"link,omitempty"`
}

Attachment names one file a document carries: a portal asset or a managed resource, exactly one of the two.

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"`
	// Format is the webhook kind's payload: ChannelFormatText (the default
	// when empty) or ChannelFormatJSON. Empty for every other kind.
	Format string `json:"format,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) PayloadFormat added in v1.138.3

func (c Channel) PayloadFormat() string

PayloadFormat returns the webhook payload format, 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 ChannelActivity added in v1.143.0

type ChannelActivity struct {
	// LastDeliveredAt is when a row to the channel was last sent.
	LastDeliveredAt *time.Time
	// LastError is the most recent failure recorded on one of the channel's
	// rows still holding one, and LastErrorAt when that row was queued. A
	// delivery clears its row's error, so a channel that recovered reads
	// clean.
	LastError   string
	LastErrorAt *time.Time
	// SentLastHour counts the sends the hourly cap counts.
	SentLastHour int
	// Pending counts rows not yet delivered: waiting, retrying, or in a
	// digest window.
	Pending int
	// Suppressed counts repeats held back across the channel's keys since
	// each key last went through.
	Suppressed int
}

ChannelActivity is what one channel has done recently: what an operator reads beside the channel's settings to tell a quiet channel from a broken one, and a channel near its cap from one far below it.

type ChannelGate added in v1.143.0

type ChannelGate struct {
	// Channel is the channel's name.
	Channel string
	// MaxPerHour is the channel's hourly cap, counted in sends.
	MaxPerHour int
	// Key is the document's suppression key. Empty skips suppression.
	Key string
	// Window is the repeat window the key is measured against.
	Window time.Duration
}

ChannelGate is what a channel send is checked against before its rows are written, in the same transaction that writes them.

type ChannelOutcome added in v1.143.0

type ChannelOutcome struct {
	// Suppressed reports a send the key absorbed: no row was written.
	Suppressed bool
}

ChannelOutcome is what the gate decided.

type ChannelResult added in v1.143.0

type ChannelResult struct {
	// Queued is how many queue rows were written: one for a chat or webhook
	// channel, one per deliverable recipient for an email channel.
	Queued int
	// Suppressed reports a send absorbed by its key's repeat window: counted
	// on the key, and stated by the next send that goes through.
	Suppressed bool
}

ChannelResult is what a send became.

type ChannelSend added in v1.143.0

type ChannelSend struct {
	Channel  Channel
	Document Document
	// Actor is who the send is charged to: the person or script sending.
	// Empty for an alert the platform raised.
	Actor string
	// RepeatAfter lengthens the channel's repeat window for the document's
	// key. A value shorter than the channel's own window is ignored: a
	// sender may make an alert quieter, never louder.
	RepeatAfter time.Duration
}

ChannelSend is one document sent to one channel.

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 Delivery added in v1.138.3

type Delivery struct {
	// ID is the delivery's stable id: "ntf_<row id>" for a queued row.
	ID string
	// OccurredAt is when the document was queued.
	OccurredAt time.Time
	// Files are the document's attachments that were read for this
	// delivery, in the document's order.
	Files []File
	// Linked are the attachments that travel as a link instead: over
	// MaxAttachmentBytes, unreadable, or on a kind that carries no files.
	Linked []Attachment
	Document
}

Delivery is one document as a channel transport sends it: the document, the identity of the queue row it came from, which is the same on every attempt so a receiver can recognize a retry, and the attachments' content.

type Document added in v1.134.0

type Document struct {
	// Title is the message's subject line: the email subject, 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"`
	// Attachments name the portal assets and managed resources that travel
	// with the document as files. They are references, never bytes: the
	// queue row stays small and the worker reads each one at the version
	// recorded here when it delivers.
	Attachments []Attachment `json:"attachments,omitempty"`
	// Key identifies "the same alert again" on its channel. A second send
	// with the same key inside the channel's repeat window is counted and
	// not delivered. Empty means every send is delivered.
	Key string `json:"key,omitempty"`
	// Suppressed is how many sends with this document's key were absorbed
	// since SuppressedSince, written by the queue when this send went
	// through after them. Every kind states it on the message's first line.
	Suppressed      int        `json:"suppressed,omitempty"`
	SuppressedSince *time.Time `json:"suppressed_since,omitempty"`
	// Type is one of the Document* types: what produced the document.
	// Empty on a document queued before types existed.
	Type string `json:"type,omitempty"`
	// Source is who produced it.
	Source *DocumentSource `json:"source,omitempty"`
	// Data is the sender's structured payload, JSON, delivered verbatim by a
	// JSON webhook channel and ignored by every other kind.
	Data json.RawMessage `json:"data,omitempty"`
}

Document is what a channel carries: a title, a markdown body, a link back to whatever produced it, and the files that travel with it. Every kind renders these 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.

func (Document) SuppressionNote added in v1.143.0

func (d Document) SuppressionNote() string

SuppressionNote is the line a document delivered after suppressed sends opens with, or empty when nothing was suppressed. Every kind renders the same sentence so a reader sees one wording wherever the alert lands.

type DocumentSource added in v1.138.3

type DocumentSource struct {
	// Kind is DocumentSourceScript, DocumentSourceUser or DocumentSourceAdmin.
	Kind string `json:"kind"`
	// Script is the producing script's reference, mcp:script:<id>.
	Script string `json:"script,omitempty"`
	// RunID is the producing run.
	RunID string `json:"run_id,omitempty"`
}

DocumentSource is who produced a document: a managed script's run, or a person.

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, s ChannelSend) (ChannelResult, error)

NotifyChannel queues a document for delivery to a channel.

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 rows of one send are written in one transaction behind the channel's gate: the hourly cap, then the key's repeat window. A send over the cap is refused with ErrChannelOverCap; a send inside its key's window writes nothing and reports Suppressed. Both are decided by the database, so they hold across replicas.

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 Queued with a nil error and Suppressed false 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 File added in v1.143.0

type File struct {
	Name        string
	ContentType string
	Data        []byte
}

File is one attachment's content, read by the worker at delivery.

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
	// Channel scopes the listing to rows sent to one notification channel.
	// Empty means any row.
	Channel 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"`
	// Cause is why a KindScriptRun run failed, one of the script.Cause*
	// values (#1859), which decides whether the alert tells the owner to fix
	// the script or that the next run should succeed. Empty for every other
	// kind, and on a row enqueued before the cause was recorded.
	Cause string `json:"cause,omitempty"`
	// Repeats is how many finished runs of a KindScriptRun alert's script
	// have now failed in a row ending on the same error, this one included
	// (#1935). A failure that repeats is one the next run is not getting past
	// on its own; a single one may be a bad moment outside the script. Zero
	// where it was not read, which renders as a first failure.
	Repeats int `json:"repeats,omitempty"`
	// Recovery is set on a KindScriptRun alert that a failing automation ran
	// successfully again (#2041), and is nil on a failure alert and every
	// other kind.
	Recovery *ScriptRecovery `json:"recovery,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
	// EnqueueChannel writes the rows of one channel send in one transaction
	// behind gate: it returns an error wrapping ErrChannelOverCap when the
	// channel has been sent gate.MaxPerHour sends in the last hour, and
	// Suppressed when gate.Key was sent inside gate.Window. A send that goes
	// through after suppressed ones carries their count and start on its
	// document.
	EnqueueChannel(ctx context.Context, rows []Notification, gate ChannelGate) (ChannelOutcome, 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)
	// PurgeChannelKeys deletes suppression keys whose window ended more than
	// retention ago, returning how many were deleted.
	PurgeChannelKeys(ctx context.Context, retention 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 RunPost added in v1.143.0

type RunPost struct {
	Channel  string
	Title    string
	QueuedAt time.Time
	// Deliveries counts the queue rows the post became; Sent and Failed how
	// many of them were delivered and how many gave up. The rest are waiting.
	Deliveries int
	Sent       int
	Failed     int
	// LastSentAt is when the last of them was delivered.
	LastSentAt *time.Time
	// LastError is a failure recorded on one of them.
	LastError string
}

RunPost is one post a managed script's run made to a channel, read back from the queue: what it was titled, where it went, and what became of it. An email channel's post is one row per recipient, gathered here into one post.

type ScriptRecovery added in v1.144.0

type ScriptRecovery struct {
	FailedRuns   int       `json:"failed_runs"`
	FailingSince time.Time `json:"failing_since"`
	RecoveredAt  time.Time `json:"recovered_at"`
	OpeningRunID string    `json:"opening_run_id"`
	OpeningCause string    `json:"opening_cause,omitempty"`
	// OpeningError is the line of the opening run's error that names the
	// failure: its last, as a traceback ends with what was raised.
	OpeningError string `json:"opening_error,omitempty"`
	// OpeningLink is the opening run's page, empty when the deployment does
	// not know its own public address.
	OpeningLink string `json:"opening_link,omitempty"`
}

ScriptRecovery is the failure incident a recovered automation's alert closes (#2041): how long it was failing and how many runs failed, and the failed run that opened it. ItemID and Link on the payload are the run that succeeded.

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