Documentation
¶
Overview ¶
Package notification is the platform's email-notification substrate: a typed admin SMTP settings store, per-user notification preferences, a durable delivery queue, and the enqueue path consulted by share and thread-comment triggers. Delivery (worker + SMTP send + branded rendering) also lives here; the platform wires it through the internal/platform/notifydelivery seam.
Index ¶
- Constants
- Variables
- func NextDigestTime(now time.Time, hourUTC int) time.Time
- func PortalLink(baseURL, route string) string
- func RecipientsExcluding(actor string, candidates ...string) []string
- func Snippet(s string) string
- func ValidMode(m string) bool
- type Branding
- type Email
- type Enqueuer
- type Listener
- type Notification
- type Payload
- type PostgresPrefsStore
- type PostgresQueueStore
- func (s *PostgresQueueStore) ClaimDigest(ctx context.Context, lease time.Duration) ([]Notification, error)
- func (s *PostgresQueueStore) ClaimImmediate(ctx context.Context, lease time.Duration) (*Notification, error)
- func (s *PostgresQueueStore) Enqueue(ctx context.Context, n Notification) error
- func (s *PostgresQueueStore) Fail(ctx context.Context, ids []int64, sendErr string) error
- func (s *PostgresQueueStore) MarkSent(ctx context.Context, ids []int64) error
- func (s *PostgresQueueStore) PurgeOld(ctx context.Context, resolvedRetention, pendingTTL time.Duration) (int64, error)
- func (s *PostgresQueueStore) Retry(ctx context.Context, ids []int64, sendErr string, backoff time.Duration) error
- type PostgresSettingsStore
- type Prefs
- type PrefsAPI
- type PrefsRequest
- type PrefsResponse
- type PrefsStore
- type PrefsUpdate
- type QueueStore
- type Renderer
- type SMTPSender
- type SMTPSettings
- type SMTPSettingsInput
- type SMTPSettingsView
- type Sender
- type SettingsStore
- type StringEncryptor
- type TestEmailRequest
- type Worker
- type WorkerConfig
Constants ¶
const ( CategoryShare = "share" // CategoryComment covers thread comments and feedback events. CategoryComment = "comment" )
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" )
Payload item kinds.
const ( // TLSModeStartTLS negotiates STARTTLS on a plaintext connection (port 587). TLSModeStartTLS = "starttls" // TLSModeImplicit opens a TLS connection directly (port 465). TLSModeImplicit = "implicit" // TLSModeNone sends in plaintext. Only for closed-network relays. TLSModeNone = "none" )
SMTP TLS modes.
const ( // DefaultPollEvery is the fallback poll interval when LISTEN/NOTIFY does // not wake the worker. DefaultPollEvery = 30 * time.Second // DefaultLease bounds one delivery attempt; an expired lease returns the // row to claimable state for crash recovery. DefaultLease = 2 * time.Minute // DefaultMaxAttempts is the delivery attempt budget per row. DefaultMaxAttempts = 5 // DefaultResolvedRetention keeps sent/failed rows for operator // inspection before the purge removes them. DefaultResolvedRetention = 30 * 24 * time.Hour // DefaultPendingTTL bounds how long an undelivered row stays relevant. // Beyond it the event is stale (nobody wants a share email from last // month when SMTP is finally configured) and the purge drops it. DefaultPendingTTL = 7 * 24 * time.Hour )
Worker defaults.
const NotifyChannel = "notifications"
NotifyChannel is the pg_notify channel that wakes the send worker when a row is enqueued. Producers fire it best-effort; the worker also polls.
const SettingsSectionSMTP = "smtp"
SettingsSectionSMTP is the platform_settings section key for SMTP.
Variables ¶
var ( // ErrNotFound is returned when a requested row does not exist. ErrNotFound = errors.New("notification: not found") // ErrNoWork is returned by claim methods when no due row is available. ErrNoWork = errors.New("notification: no work available") // ErrSMTPNotConfigured is returned by delivery actions when SMTP is // absent, disabled, or missing a host; the caller should surface it as // a configuration conflict, not a delivery failure. ErrSMTPNotConfigured = errors.New("notification: smtp is disabled or not configured") )
Sentinel errors returned by the stores in this package.
Functions ¶
func NextDigestTime ¶
NextDigestTime returns the next occurrence of hourUTC:00 strictly after now. The result is in UTC.
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 (case-insensitive) and empties. Used to fan a thread event out to the target owner and thread author without self-notification.
Types ¶
type Branding ¶
type Branding struct {
// Name is the portal brand/title, e.g. "ACME Data Platform".
Name string
// BaseURL is the portal's public base URL used for deep links.
BaseURL string
// ImplementorName and ImplementorURL render in the footer when set.
ImplementorName string
ImplementorURL string
// LogoPNG is the raster logo from portal.logo_email, resolved once at
// startup. When non-empty it is attached to every message as an inline
// (cid:) part and rendered above the wordmark; recipients never fetch it
// remotely, so it survives the image blocking most clients apply by
// default. Empty renders the wordmark alone.
LogoPNG []byte
}
Branding carries the deployment identity emails render with. Values come from the same PortalConfig the portal UI uses, so emails match the portal. The brand name always renders as a styled wordmark linked to BaseURL; the portal's SVG logo is never usable here because email clients strip inline SVG, so LogoPNG carries a separate raster asset when the operator supplies one.
type Email ¶
type Email struct {
To string
Subject string
HTML string
Text string
// LogoPNG is the inline logo the sender must attach under logoContentID
// when non-empty. The HTML references it but cannot carry the bytes.
LogoPNG []byte
}
Email is one rendered message ready for an SMTP sender.
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 ¶
Notify queues one notification for recipient according to their preferences. Events targeting nobody (empty recipient) or the actor themselves are dropped silently, as are events the recipient opted out of. A nil Enqueuer (feature not wired, e.g. no database) drops everything.
type Listener ¶
type Listener struct {
// contains filtered or unexported fields
}
Listener is the LISTEN side of the queue's LISTEN/NOTIFY adapter. Enqueue fires NOTIFY; this goroutine receives it and wakes the worker so immediate notifications go out without waiting for the poll interval.
func NewListener ¶
NewListener constructs a Listener for the supplied DSN. It does not connect until Start is called.
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"`
}
Payload carries the event details a template needs to render an email. It is stored as the queue row's JSONB payload.
type PostgresPrefsStore ¶
type PostgresPrefsStore struct {
// contains filtered or unexported fields
}
PostgresPrefsStore implements PrefsStore backed by user_notification_prefs.
func NewPostgresPrefsStore ¶
func NewPostgresPrefsStore(db *sql.DB) *PostgresPrefsStore
NewPostgresPrefsStore creates a PostgreSQL-backed preferences store.
func (*PostgresPrefsStore) Set ¶
func (s *PostgresPrefsStore) Set(ctx context.Context, email string, u PrefsUpdate) (Prefs, error)
Set applies u over the user's current (or default) preferences and upserts the result.
type PostgresQueueStore ¶
type PostgresQueueStore struct {
// contains filtered or unexported fields
}
PostgresQueueStore implements QueueStore backed by the notifications table.
func NewPostgresQueueStore ¶
func NewPostgresQueueStore(db *sql.DB) *PostgresQueueStore
NewPostgresQueueStore creates a PostgreSQL-backed notification queue.
func (*PostgresQueueStore) ClaimDigest ¶
func (s *PostgresQueueStore) ClaimDigest(ctx context.Context, lease time.Duration) ([]Notification, error)
ClaimDigest claims all due digest rows for the recipient with the oldest due digest row. Concurrent workers racing for the same recipient are safe: the loser's UPDATE matches zero rows and reports ErrNoWork.
func (*PostgresQueueStore) ClaimImmediate ¶
func (s *PostgresQueueStore) ClaimImmediate(ctx context.Context, lease time.Duration) (*Notification, error)
ClaimImmediate claims the next due non-digest row.
func (*PostgresQueueStore) Enqueue ¶
func (s *PostgresQueueStore) Enqueue(ctx context.Context, n Notification) error
Enqueue inserts a pending notification row and fires a best-effort pg_notify so a listening worker wakes without waiting for the next poll.
func (*PostgresQueueStore) MarkSent ¶
func (s *PostgresQueueStore) MarkSent(ctx context.Context, ids []int64) error
MarkSent transitions claimed rows to sent.
type PostgresSettingsStore ¶
type PostgresSettingsStore struct {
// contains filtered or unexported fields
}
PostgresSettingsStore implements SettingsStore backed by the platform_settings table. Secrets inside a section value are encrypted with the injected StringEncryptor before they reach the database.
func NewPostgresSettingsStore ¶
func NewPostgresSettingsStore(db *sql.DB, enc StringEncryptor) *PostgresSettingsStore
NewPostgresSettingsStore creates a PostgreSQL-backed settings store. The encryptor may be nil-safe (encryption disabled) but must not be nil.
func (*PostgresSettingsStore) GetSMTP ¶
func (s *PostgresSettingsStore) GetSMTP(ctx context.Context) (*SMTPSettings, error)
GetSMTP returns the stored SMTP settings with the password decrypted.
func (*PostgresSettingsStore) SetSMTP ¶
func (s *PostgresSettingsStore) SetSMTP(ctx context.Context, in SMTPSettings, author string) error
SetSMTP upserts the SMTP settings. An empty incoming password keeps the previously stored (still encrypted) one so the admin UI never round-trips the secret.
type Prefs ¶
type Prefs struct {
Email string `json:"email"`
Mode string `json:"mode"`
CommentsEnabled bool `json:"comments_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 PrefsAPI ¶
type PrefsAPI struct {
Store PrefsStore
// UserEmail resolves the authenticated user's email from the request,
// returning "" when unauthenticated.
UserEmail func(*http.Request) string
}
PrefsAPI serves the self-scoped notification preference REST endpoints. It registers onto the portal's authenticated mux via a registrar hook (the datahubapi pattern) so the feature lives in this package; the composition root supplies UserEmail to resolve the authenticated caller. Server-side self-scope: the caller's email is the only key ever read or written.
type PrefsRequest ¶
type PrefsRequest struct {
Mode *string `json:"mode,omitempty"`
CommentsEnabled *bool `json:"comments_enabled,omitempty"`
}
PrefsRequest is the body for updating the caller's preferences. Omitted fields are left unchanged.
type PrefsResponse ¶
type PrefsResponse struct {
Mode string `json:"mode"`
CommentsEnabled bool `json:"comments_enabled"`
}
PrefsResponse is the user-facing preferences shape.
type PrefsStore ¶
type PrefsStore interface {
// Get returns the user's preferences, falling back to DefaultPrefs when
// no row exists. It never returns ErrNotFound.
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"`
}
PrefsUpdate carries the fields of a preferences write; nil fields keep the current (or default) value.
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.
type Renderer ¶
type Renderer struct {
// contains filtered or unexported fields
}
Renderer renders queued notifications into branded multipart emails.
func NewRenderer ¶
NewRenderer parses the embedded templates for the given branding.
type SMTPSender ¶
type SMTPSender struct{}
SMTPSender implements Sender over SMTP via go-mail. A fresh connection is dialed per send: notification volume is human-scale and a stateless dial keeps credential and TLS changes immediate.
func NewSMTPSender ¶
func NewSMTPSender() *SMTPSender
NewSMTPSender creates the production SMTP sender.
func (*SMTPSender) Send ¶
func (*SMTPSender) Send(ctx context.Context, settings SMTPSettings, email Email) error
Send renders no content itself; it wraps email into a multipart message (plaintext body + HTML alternative) and delivers it.
type SMTPSettings ¶
type SMTPSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
// From is the sender address, e.g. "platform@example.com".
From string `json:"from"`
// FromName is the optional display name for the From address.
FromName string `json:"from_name"`
// TLSMode is one of the TLSMode* constants.
TLSMode string `json:"tls_mode"`
// UpdatedBy and UpdatedAt describe the last admin write.
UpdatedBy string `json:"updated_by,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
SMTPSettings is the admin-configured mail server connection. The password is encrypted at rest and never returned by the admin API.
func (*SMTPSettings) View ¶
func (s *SMTPSettings) View() SMTPSettingsView
View maps stored settings to the password-free read shape.
type SMTPSettingsInput ¶
type SMTPSettingsInput struct {
Enabled bool `json:"enabled" example:"true"`
Host string `json:"host" example:"smtp.example.com"`
Port int `json:"port" example:"587"`
Username string `json:"username,omitempty" example:"mailer@example.com"`
Password string `json:"password,omitempty" example:"app-password"`
From string `json:"from" example:"platform@example.com"`
FromName string `json:"from_name,omitempty" example:"Data Platform"`
TLSMode string `json:"tls_mode,omitempty" example:"starttls"`
}
SMTPSettingsInput is the write shape for the admin SMTP configuration. An empty password keeps the currently stored one.
func (*SMTPSettingsInput) Settings ¶
func (in *SMTPSettingsInput) Settings() SMTPSettings
Settings maps the validated input to store settings.
func (*SMTPSettingsInput) Validate ¶
func (in *SMTPSettingsInput) Validate() string
Validate normalizes defaults in place and returns a non-empty message when the input is invalid. Omitted fields take defaults (port 587, STARTTLS) so a minimal `{"enabled": false}` disable call works.
type SMTPSettingsView ¶
type SMTPSettingsView struct {
Enabled bool `json:"enabled" example:"true"`
Host string `json:"host" example:"smtp.example.com"`
Port int `json:"port" example:"587"`
Username string `json:"username" example:"mailer@example.com"`
PasswordSet bool `json:"password_set" example:"true"`
From string `json:"from" example:"platform@example.com"`
FromName string `json:"from_name" example:"Data Platform"`
TLSMode string `json:"tls_mode" example:"starttls"`
UpdatedBy string `json:"updated_by,omitempty" example:"admin@example.com"`
UpdatedAt time.Time `json:"updated_at"`
}
SMTPSettingsView is the read shape for the admin SMTP configuration. The password is write-only: the view reports only whether one is stored.
func UnconfiguredSMTPView ¶
func UnconfiguredSMTPView() SMTPSettingsView
UnconfiguredSMTPView is the read shape served before SMTP has ever been configured: disabled, with the transport defaults prefilled.
type Sender ¶
type Sender interface {
Send(ctx context.Context, settings SMTPSettings, email Email) error
}
Sender delivers one rendered email using the given SMTP settings. Settings are passed per call so admin changes apply to the next send without any worker restart.
type SettingsStore ¶
type SettingsStore interface {
// GetSMTP returns the stored SMTP settings with the password decrypted,
// or ErrNotFound when SMTP has never been configured.
GetSMTP(ctx context.Context) (*SMTPSettings, error)
// SetSMTP upserts the SMTP settings, encrypting the password at rest.
// An empty incoming password preserves the previously stored one so the
// admin UI can stay write-only without re-sending the secret.
SetSMTP(ctx context.Context, s SMTPSettings, author string) error
}
SettingsStore persists admin-editable platform settings sections. SMTP is the first section; future global settings reuse the same table.
type StringEncryptor ¶
type StringEncryptor interface {
Encrypt(plaintext string) (string, error)
Decrypt(ciphertext string) (string, error)
}
StringEncryptor encrypts and decrypts a single string secret. Satisfied by fieldcrypt.RestFieldEncryptor; a nil-safe passthrough is acceptable when encryption is disabled.
type TestEmailRequest ¶
type TestEmailRequest struct {
To string `json:"to" example:"admin@example.com"`
}
TestEmailRequest is the body for the admin send-test-email action.
func (*TestEmailRequest) Validate ¶
func (r *TestEmailRequest) Validate() string
Validate returns a non-empty message when the recipient is invalid.
type Worker ¶
type Worker struct {
// contains filtered or unexported fields
}
Worker drains the notification queue: it claims due rows, renders branded emails, and delivers them over SMTP. It follows the indexjobs worker shape (poll ticker + LISTEN/NOTIFY wakeup, lease-based claiming, retry with exponential backoff). When SMTP is unconfigured or disabled the worker leaves rows pending without burning delivery attempts.
func NewWorker ¶
func NewWorker(cfg WorkerConfig) *Worker
NewWorker creates a send worker, applying defaults for zero config values.
func (*Worker) Notify ¶
func (w *Worker) Notify()
Notify wakes the worker without waiting for the next poll tick. Safe to call from any goroutine; a flurry of calls coalesces into one wakeup.
type WorkerConfig ¶
type WorkerConfig struct {
Queue QueueStore
Settings SettingsStore
Renderer *Renderer
Sender Sender
// PollEvery, Lease, and MaxAttempts default to the package constants
// when zero.
PollEvery time.Duration
Lease time.Duration
MaxAttempts int
}
WorkerConfig configures the send worker.