Documentation
¶
Overview ¶
Package notify turns an outbox event into a message on a channel (§11, ADR-019).
It knows three things and nothing else: how severe an event is, how to word it, and how to hand it to a provider. Whether an event *should* be sent — routing, debouncing, quiet hours — belongs to the dispatcher, which owns the rules and the delivery history.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ValidateConfig ¶
ValidateConfig checks a channel's configuration before it is stored. A channel is validated for the transport it claims to be: accepting an SMTP channel with no host would create an alerting path that can only fail at the moment it is needed.
Types ¶
type Config ¶
type Config struct {
URL string `json:"url,omitempty"`
SMTP *SMTPConfig `json:"smtp,omitempty"`
Resend *ResendConfig `json:"resend,omitempty"`
Telegram *TelegramConfig `json:"telegram,omitempty"`
Pushover *PushoverConfig `json:"pushover,omitempty"`
}
Config is the decrypted configuration of a channel. Only the fields the channel's kind needs are set. The whole struct is envelope-encrypted at rest (ADR-003): a bot token and an SMTP password are credentials, not settings.
type Dispatcher ¶
type Dispatcher struct {
Store NotificationStore
Keyring *envelope.Keyring
Sender *Sender
Logger *slog.Logger
}
Dispatcher turns outbox events into deliveries (ADR-019). It runs on the elected scheduler leader, so its cursor needs no locking.
The outbox is the single source of truth: notifications are one consumer among others (SSE reads the same table), which is why the cursor lives here and not in the outbox rows.
func (*Dispatcher) Dispatch ¶
func (d *Dispatcher) Dispatch(ctx context.Context)
Dispatch reads the events published since the last pass and delivers them. The cursor only advances over events that were fully considered: an event whose delivery failed keeps its row (status failed) and is not re-read, but an event we never got to is read again next time.
func (*Dispatcher) FlushDigests ¶
func (d *Dispatcher) FlushDigests(ctx context.Context)
FlushDigests sends one grouped message per digest rule whose window has elapsed (ADR-019 §4): "what did not need to wake me up, told once".
The pending deliveries ARE the queue — there is no second table to keep in sync, and a crash mid-flush simply leaves them pending for the next pass.
type Event ¶
type Event struct {
Type string `json:"event_type"`
Severity string `json:"severity"`
OccurredAt time.Time `json:"occurred_at"`
TeamUUID string `json:"team_uuid,omitempty"`
Resource string `json:"resource_uuid,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
// Suppressed counts the events this one stands for: a debounced alert
// says "and 12 others" instead of hiding them (ADR-019).
Suppressed int `json:"suppressed_count,omitempty"`
}
Event is what a channel is asked to deliver.
type NotificationStore ¶
type NotificationStore interface {
GetNotificationCursor(context.Context) (int64, error)
ListOutboxEventsAfter(context.Context, store.ListOutboxEventsAfterParams) ([]store.OutboxEvent, error)
SetNotificationCursor(context.Context, int64) error
ResolveProjectEnvironmentOfResource(context.Context, pgtype.UUID) (store.ResolveProjectEnvironmentOfResourceRow, error)
MatchNotificationRules(context.Context, store.MatchNotificationRulesParams) ([]store.MatchNotificationRulesRow, error)
CreateNotificationDelivery(context.Context, store.CreateNotificationDeliveryParams) (store.NotificationDelivery, error)
FinishNotificationDelivery(context.Context, store.FinishNotificationDeliveryParams) error
GetNotificationChannelByID(context.Context, int64) (store.NotificationChannel, error)
LastSentDelivery(context.Context, int64) (pgtype.Timestamptz, error)
CountSuppressedSince(context.Context, store.CountSuppressedSinceParams) (int64, error)
ListDigestRulesDue(context.Context) ([]store.ListDigestRulesDueRow, error)
ListPendingDigestDeliveries(context.Context, int64) ([]store.ListPendingDigestDeliveriesRow, error)
MarkDigestDeliveriesFailed(context.Context, store.MarkDigestDeliveriesFailedParams) error
MarkDigestDeliveriesSent(context.Context, []int64) error
SetRuleDigestFlushed(context.Context, int64) error
}
NotificationStore is the narrow database boundary used by the dispatcher. Keeping it here makes the routing and noise-control rules unit-testable without starting PostgreSQL.
type PushoverConfig ¶
PushoverConfig posts to the Pushover message API.
type ResendConfig ¶
type ResendConfig struct {
APIKey string `json:"api_key"`
From string `json:"from"`
To []string `json:"to"`
}
ResendConfig posts to the Resend HTTP API.
type SMTPConfig ¶
type SMTPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
From string `json:"from"`
To []string `json:"to"`
Encryption string `json:"encryption"` // starttls | tls | none
}
SMTPConfig talks to a mail relay. `Encryption` is explicit rather than guessed from the port: a channel that silently downgrades to plaintext would put the password and the alert on the wire in clear, and nothing would say so.
type Sender ¶
Sender delivers events to a channel.
func New ¶
func New() *Sender
New builds a sender. The timeout is short: a channel that hangs must not hold the dispatcher, and a missed alert is retried on the next pass.
The HTTP client is SSRF-guarded (safedial): a notification channel's webhook URL is set by team members (notifications:manage), so it is attacker- influenceable — the classic SSRF vector is a "test channel" call pointed at 169.254.169.254. Blocking non-public destinations here closes it. (The SMTP dial below is NOT guarded: the relay is instance-root configuration and may legitimately be an internal host.)
type Severity ¶
type Severity int
Severity orders how much an event deserves to wake someone up (ADR-019).
The severity ladder: info is routine, critical wakes someone up.
func ParseSeverity ¶
ParseSeverity reads the enum stored on a rule.
func SeverityOf ¶
SeverityOf classifies an event type. The taxonomy is the load-bearing part of ADR-019: a critical event wrongly classified as info would be deferred into a digest, which is the one failure mode the ADR calls out. So the default is deliberately NOT info — an unknown event that mentions a failure is treated as a failure.
type TelegramConfig ¶
type TelegramConfig struct {
BotToken string `json:"bot_token"`
ChatID string `json:"chat_id"`
TopicID string `json:"topic_id,omitempty"`
}
TelegramConfig posts to the Bot API.