[NLnet] Core developments: notifications system #716

Merged
nemunaire merged 10 commits from f/notifications into master 2026-04-30 14:05:01 +00:00
50 changed files with 5502 additions and 18 deletions

View file

@ -0,0 +1,110 @@
# Checker notifications
Notifications turn checker results into user-visible alerts. The system watches
every completed `Execution`, decides whether the status transition deserves an
alert under the user's preferences, and fans the message out to one or more
delivery channels (email, webhook, UnifiedPush). Audit records and per-issue
acknowledgements are kept so the user can see what was sent, and silence noisy
incidents until they recover.
## Goals
- **Transition driven, not poll driven.** Only state changes notify. Identical
back to back results stay silent. This is what keeps a flapping checker from
paging on every run.
- **Per user, per scope policy.** A user can configure preferences globally,
per domain, or per service, and the most specific rule wins (service over
domain over global). Channels are owned by the user, and may be allow listed
per preference.
- **Opt in by default.** A user with no configured preference still receives
`warn` and above alerts on all enabled channels, so onboarding does not
require authoring rules. A configured preference can lower or raise that
bar, suppress recovery notices, or set quiet hours.
- **Acknowledgement closes the loop.** A user can acknowledge an active issue
and stop further alerts at the same severity until the incident recovers or
escalates.
- **Auditable delivery.** Every send, and every failure (including back
pressure drops), is recorded so users can confirm an alert really left the
building.
- **Decoupled from the checker engine.** Notification dispatch hangs off a
callback the engine fires after each execution, so a slow channel cannot
wedge a checker run.
## Architecture
The checker engine fires a registered callback after each `ExecutionDone`. The
callback enters the notification subsystem, which loads the user's state and
preferences, runs a pure policy function over the status transition, persists
the new state, and on a positive decision enqueues sends onto a bounded worker
pool. Workers resolve the matching sender from a type indexed registry, and
write a record of the result into the audit log.
State, preferences, channels, and records each have a dedicated storage
interface. A per `(checker, target, user)` mutex serialises the read modify
write done by the dispatcher and by manual acknowledgement actions, so an ack
cannot be silently overwritten by a concurrent checker run. The HTTP layer
(`internal/api/route/notification.go`) exposes CRUD on channels, preferences,
history, and the acknowledge and clear endpoints scoped to a checker. The
Svelte UI under `web/src/routes/me/notifications/` drives all of this.
Key types:
- `Dispatcher` (`internal/usecase/notification/dispatcher.go`): the seam
between checker and notification, glues every collaborator together and
owns no I/O of its own.
- `Resolver`: picks the most specific preference for a target, and the
channels (filtered by the preference allow list) that should carry the
alert.
- `policy.decide`: pure function returning skip, advance, or notify, plus
recovery, escalation, and clear ack flags. Unit tested.
- `StateLocker`: in process per key mutex shared by the dispatcher and the
ack service.
- `Pool`: bounded queue (256) and fixed worker pool (4) with a 15s send
timeout, records every result and writes an audit row on saturation rather
than dropping silently.
- `Registry` and `ChannelSender` (`internal/notification/sender.go`): typed
registry of transports, with `TypedSender[C]` and `Adapt` providing
decode, validate, redact, merge, and send test for free.
- `AckService`: acknowledge, clear, get, and list state, behind the same
state lock.
- Models (`model/notification.go`): `NotificationChannel`,
`NotificationPreference`, `NotificationState`, `NotificationRecord`.
### Decision flow inside `policy.decide`
1. `oldStatus == newStatus` returns skip.
2. Compute `isRecovery` (`newStatus < warn` while `oldStatus >= warn`) and
`isEscalation` (`newStatus > oldStatus && newStatus >= warn`). Either one
sets `ClearAck`, since the incident is over or has worsened.
3. No preference, or `pref.Enabled=false`, returns advance (record the
transition, do not send).
4. Non recovery below `pref.MinStatus` returns advance.
5. Recovery while `!pref.NotifyRecovery` returns advance.
6. Active acknowledgement, with `!ClearAck`, and not a recovery, returns
advance. The user already knows.
7. Inside `pref.QuietStart..QuietEnd` (UTC, wraps midnight if start is
greater than end) returns advance.
8. Otherwise, notify.
Advance updates `LastStatus` only. Notify also stamps `LastNotifiedAt`, and
this stamp is written **before** enqueuing, so a fast re run sees the new
status and skips, even if the worker pool has not yet drained.
## Existing channel implementations
All senders live in `internal/notification/`. They share `safe_http.go`
(outbound URL validation against private and loopback ranges, redirect
bounds), and where relevant `httpjson.go` for JSON POSTs. Each registers a
constant of type `happydns.NotificationChannelType`.
| Type | Constant | Config (JSON) | Notes |
|---------------|-------------------------|---------------------------------------------|-----------------------------------------------------------------------------------|
| `email` | `ChannelTypeEmail` | `{ "address"?: string }` | Falls back to the user's account email. Renders via the existing `Mailer`. Base URL captured at construction. |
| `webhook` | `ChannelTypeWebhook` | `{ "url": string, "secret"?: string, ... }` | POSTs JSON. Optional HMAC SHA256 signature header derived from `secret`. |
| `unifiedpush` | `ChannelTypeUnifiedPush`| `{ "endpoint": string }` | POST to the distributor provided endpoint. URL is validated against the safe HTTP allow list. |
Each sender is implemented as a `TypedSender[C]` and exposed through `Adapt`,
so JSON decode, `Validate`, redact, merge, and `SendTest` are uniform across
transports. Senders that carry secrets implement `ConfigRedactor[C]`, so the
client never sees the secret, and `ConfigMerger[C]`, so an empty value on
update preserves the stored secret rather than wiping it.

View file

@ -0,0 +1,468 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
notifPkg "git.happydns.org/happyDomain/internal/notification"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/model"
)
// Caps ?limit= so an unbounded request can't OOM the in-memory slice.
const maxHistoryLimit = 500
// Bounds the persisted annotation to prevent state bloat.
const maxAnnotationLength = 1024
// Storage errors may contain keys/internals; never echo them back.
func internalError(c *gin.Context, err error) {
log.Printf("notification controller: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{
Message: "internal server error",
})
}
type NotificationController struct {
dispatcher *notifUC.Dispatcher
registry *notifPkg.Registry
channelStore notifUC.NotificationChannelStorage
prefStore notifUC.NotificationPreferenceStorage
recordStore notifUC.NotificationRecordStorage
}
func NewNotificationController(
dispatcher *notifUC.Dispatcher,
registry *notifPkg.Registry,
channelStore notifUC.NotificationChannelStorage,
prefStore notifUC.NotificationPreferenceStorage,
recordStore notifUC.NotificationRecordStorage,
) *NotificationController {
return &NotificationController{
dispatcher: dispatcher,
registry: registry,
channelStore: channelStore,
prefStore: prefStore,
recordStore: recordStore,
}
}
// @Summary List supported notification channel types
// @Tags notifications
// @Produce json
// @Success 200 {array} string
// @Router /notifications/channel-types [get]
func (nc *NotificationController) ListChannelTypes(c *gin.Context) {
c.JSON(http.StatusOK, nc.registry.Types())
}
// @Summary List notification channels
// @Tags notifications
// @Produce json
// @Success 200 {array} happydns.NotificationChannel
// @Router /notifications/channels [get]
func (nc *NotificationController) ListChannels(c *gin.Context) {
user := middleware.MyUser(c)
channels, err := nc.channelStore.ListChannelsByUser(user.Id)
if err != nil {
internalError(c, err)
return
}
redacted, err := nc.registry.RedactChannels(channels)
if err != nil {
internalError(c, err)
return
}
if redacted == nil {
redacted = []*happydns.NotificationChannel{}
}
c.JSON(http.StatusOK, redacted)
}
// @Summary Create a notification channel
// @Tags notifications
// @Accept json
// @Produce json
// @Param body body happydns.NotificationChannel true "Channel configuration"
// @Success 201 {object} happydns.NotificationChannel
// @Router /notifications/channels [post]
func (nc *NotificationController) CreateChannel(c *gin.Context) {
user := middleware.MyUser(c)
var ch happydns.NotificationChannel
if err := c.ShouldBindJSON(&ch); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
ch.UserId = user.Id
if _, err := nc.registry.DecodeChannelConfig(&ch); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
if err := nc.channelStore.CreateChannel(&ch); err != nil {
internalError(c, err)
return
}
redacted, err := nc.registry.RedactChannel(&ch)
if err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusCreated, redacted)
}
// @Summary Get a notification channel
// @Tags notifications
// @Produce json
// @Param channelId path string true "Channel ID"
// @Success 200 {object} happydns.NotificationChannel
// @Router /notifications/channels/{channelId} [get]
func (nc *NotificationController) GetChannel(c *gin.Context) {
redacted, err := nc.registry.RedactChannel(middleware.MyNotificationChannel(c))
if err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusOK, redacted)
}
// Absent body fields are preserved so omitting one (e.g. "enabled") doesn't silently zero it.
//
// @Summary Update a notification channel
// @Tags notifications
// @Accept json
// @Produce json
// @Param channelId path string true "Channel ID"
// @Param body body happydns.NotificationChannel true "Channel configuration"
// @Success 200 {object} happydns.NotificationChannel
// @Router /notifications/channels/{channelId} [put]
func (nc *NotificationController) UpdateChannel(c *gin.Context) {
existing := middleware.MyNotificationChannel(c)
// Bind onto a copy so json.Unmarshal only overwrites present fields; identity fields are forced back below.
ch := *existing
if err := c.ShouldBindJSON(&ch); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
ch.Id = existing.Id
ch.UserId = existing.UserId
// Carry forward stored secrets so a GET → PUT round-trip does not wipe them.
merged, err := nc.registry.MergeChannelForUpdate(existing, &ch)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
ch.Config = merged
if _, err := nc.registry.DecodeChannelConfig(&ch); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
if err := nc.channelStore.UpdateChannel(&ch); err != nil {
internalError(c, err)
return
}
redacted, err := nc.registry.RedactChannel(&ch)
if err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusOK, redacted)
}
// @Summary Delete a notification channel
// @Tags notifications
// @Param channelId path string true "Channel ID"
// @Success 204
// @Router /notifications/channels/{channelId} [delete]
func (nc *NotificationController) DeleteChannel(c *gin.Context) {
ch := middleware.MyNotificationChannel(c)
if err := nc.channelStore.DeleteChannel(ch.Id); err != nil {
internalError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary Send a test notification
// @Tags notifications
// @Param channelId path string true "Channel ID"
// @Success 200 {object} map[string]string
// @Router /notifications/channels/{channelId}/test [post]
func (nc *NotificationController) TestChannel(c *gin.Context) {
user := middleware.MyUser(c)
ch := middleware.MyNotificationChannel(c)
if err := nc.dispatcher.SendTestNotification(ch, user); err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// Hours, when set, must be in 023. Timezone, when set, must be a valid IANA name.
func validateQuietHours(p *happydns.NotificationPreference) error {
if p.QuietStart != nil && (*p.QuietStart < 0 || *p.QuietStart > 23) {
return fmt.Errorf("quietStart must be between 0 and 23")
}
if p.QuietEnd != nil && (*p.QuietEnd < 0 || *p.QuietEnd > 23) {
return fmt.Errorf("quietEnd must be between 0 and 23")
}
if p.Timezone != "" {
if _, err := time.LoadLocation(p.Timezone); err != nil {
return fmt.Errorf("timezone %q is not a valid IANA name", p.Timezone)
}
}
return nil
}
// @Summary List notification preferences
// @Tags notifications
// @Produce json
// @Success 200 {array} happydns.NotificationPreference
// @Router /notifications/preferences [get]
func (nc *NotificationController) ListPreferences(c *gin.Context) {
user := middleware.MyUser(c)
prefs, err := nc.prefStore.ListPreferencesByUser(user.Id)
if err != nil {
internalError(c, err)
return
}
if prefs == nil {
prefs = []*happydns.NotificationPreference{}
}
c.JSON(http.StatusOK, prefs)
}
// @Summary Create a notification preference
// @Tags notifications
// @Accept json
// @Produce json
// @Param body body happydns.NotificationPreference true "Preference configuration"
// @Success 201 {object} happydns.NotificationPreference
// @Router /notifications/preferences [post]
func (nc *NotificationController) CreatePreference(c *gin.Context) {
user := middleware.MyUser(c)
var pref happydns.NotificationPreference
if err := c.ShouldBindJSON(&pref); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
pref.UserId = user.Id
if err := validateQuietHours(&pref); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
if err := nc.prefStore.CreatePreference(&pref); err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusCreated, pref)
}
// @Summary Get a notification preference
// @Tags notifications
// @Produce json
// @Param prefId path string true "Preference ID"
// @Success 200 {object} happydns.NotificationPreference
// @Router /notifications/preferences/{prefId} [get]
func (nc *NotificationController) GetPreference(c *gin.Context) {
c.JSON(http.StatusOK, middleware.MyNotificationPreference(c))
}
// Absent body fields preserved (see UpdateChannel).
//
// @Summary Update a notification preference
// @Tags notifications
// @Accept json
// @Produce json
// @Param prefId path string true "Preference ID"
// @Param body body happydns.NotificationPreference true "Preference configuration"
// @Success 200 {object} happydns.NotificationPreference
// @Router /notifications/preferences/{prefId} [put]
func (nc *NotificationController) UpdatePreference(c *gin.Context) {
existing := middleware.MyNotificationPreference(c)
pref := *existing
if err := c.ShouldBindJSON(&pref); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
pref.Id = existing.Id
pref.UserId = existing.UserId
if err := validateQuietHours(&pref); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
if err := nc.prefStore.UpdatePreference(&pref); err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusOK, pref)
}
// @Summary Delete a notification preference
// @Tags notifications
// @Param prefId path string true "Preference ID"
// @Success 204
// @Router /notifications/preferences/{prefId} [delete]
func (nc *NotificationController) DeletePreference(c *gin.Context) {
pref := middleware.MyNotificationPreference(c)
if err := nc.prefStore.DeletePreference(pref.Id); err != nil {
internalError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// @Summary List notification history
// @Tags notifications
// @Produce json
// @Param limit query int false "Maximum number of records (capped at 500)" default(50)
// @Success 200 {array} happydns.NotificationRecord
// @Router /notifications/history [get]
func (nc *NotificationController) ListHistory(c *gin.Context) {
user := middleware.MyUser(c)
limit := 50
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if limit > maxHistoryLimit {
limit = maxHistoryLimit
}
records, err := nc.recordStore.ListRecordsByUser(user.Id, limit)
if err != nil {
internalError(c, err)
return
}
if records == nil {
records = []*happydns.NotificationRecord{}
}
c.JSON(http.StatusOK, records)
}
// @Summary Acknowledge a checker issue
// @Tags checkers
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param checkerId path string true "Checker ID"
// @Param body body happydns.AcknowledgeRequest true "Acknowledgement"
// @Success 200 {object} happydns.NotificationState
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [post]
func (nc *NotificationController) AcknowledgeIssue(c *gin.Context) {
user := middleware.MyUser(c)
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var req happydns.AcknowledgeRequest
if err := c.ShouldBindJSON(&req); err != nil {
// Body is optional for acknowledgement.
req = happydns.AcknowledgeRequest{}
}
if len(req.Annotation) > maxAnnotationLength {
req.Annotation = req.Annotation[:maxAnnotationLength]
}
if err := nc.dispatcher.AcknowledgeIssue(user.Id, checkerID, target, user.Email, req.Annotation); err != nil {
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
internalError(c, err)
return
}
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
if err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusOK, state)
}
// @Summary Clear acknowledgement
// @Tags checkers
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} happydns.NotificationState
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [delete]
func (nc *NotificationController) ClearAcknowledgement(c *gin.Context) {
user := middleware.MyUser(c)
target := targetFromContext(c)
checkerID := c.Param("checkerId")
if err := nc.dispatcher.ClearAcknowledgement(user.Id, checkerID, target); err != nil {
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
internalError(c, err)
return
}
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
if err != nil {
internalError(c, err)
return
}
c.JSON(http.StatusOK, state)
}

View file

@ -0,0 +1,116 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package middleware
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/model"
)
const (
ctxKeyNotificationChannel = "notification_channel"
ctxKeyNotificationPreference = "notification_preference"
)
// Centralizes ownership check so per-channel endpoints cannot forget it.
func NotificationChannelHandler(store notifUC.NotificationChannelStorage) gin.HandlerFunc {
return func(c *gin.Context) {
user := MyUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined."})
return
}
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
return
}
ch, err := store.GetChannel(channelId)
if err != nil {
if errors.Is(err, happydns.ErrNotificationChannelNotFound) {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
if !ch.UserId.Equals(user.Id) {
// 404 not 403: do not leak the existence of channels owned by others.
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
return
}
c.Set(ctxKeyNotificationChannel, ch)
c.Next()
}
}
// Panics if middleware not installed — wiring bug, not runtime.
func MyNotificationChannel(c *gin.Context) *happydns.NotificationChannel {
return c.MustGet(ctxKeyNotificationChannel).(*happydns.NotificationChannel)
}
func NotificationPreferenceHandler(store notifUC.NotificationPreferenceStorage) gin.HandlerFunc {
return func(c *gin.Context) {
user := MyUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined."})
return
}
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
return
}
pref, err := store.GetPreference(prefId)
if err != nil {
if errors.Is(err, happydns.ErrNotificationPreferenceNotFound) {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
if !pref.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
return
}
c.Set(ctxKeyNotificationPreference, pref)
c.Next()
}
}
func MyNotificationPreference(c *gin.Context) *happydns.NotificationPreference {
return c.MustGet(ctxKeyNotificationPreference).(*happydns.NotificationPreference)
}

View file

@ -68,7 +68,8 @@ func DeclareCheckerRoutes(
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
// nc may be nil if the notification system is not configured.
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController, nc *controller.NotificationController) {
checkers := scopedRouter.Group("/checkers")
checkers.GET("", cc.ListAvailableChecks)
checkers.GET("/metrics", cc.GetDomainMetrics)
@ -113,4 +114,10 @@ func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.Ch
// Results (under execution).
executionID.GET("/results", cc.GetExecutionResults)
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
// Acknowledgement (requires notification system).
if nc != nil {
checkerID.POST("/acknowledge", nc.AcknowledgeIssue)
checkerID.DELETE("/acknowledge", nc.ClearAcknowledgement)
}
}

View file

@ -43,6 +43,7 @@ func DeclareDomainRoutes(
cc *controller.CheckerController,
checkStatusUC *checkerUC.CheckStatusUsecase,
domainInfoUC happydns.DomainInfoUsecase,
nc *controller.NotificationController,
) {
dc := controller.NewDomainController(
domainUC,
@ -72,7 +73,7 @@ func DeclareDomainRoutes(
// Mount domain-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc, nc)
}
DeclareZoneRoutes(
@ -83,5 +84,6 @@ func DeclareDomainRoutes(
zoneServiceUC,
serviceUC,
cc,
nc,
)
}

View file

@ -0,0 +1,99 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
notifPkg "git.happydns.org/happyDomain/internal/notification"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
happydns "git.happydns.org/happyDomain/model"
)
func DeclareNotificationRoutes(
apiAuthRoutes *gin.RouterGroup,
dispatcher *notifUC.Dispatcher,
registry *notifPkg.Registry,
channelStore notifUC.NotificationChannelStorage,
prefStore notifUC.NotificationPreferenceStorage,
recordStore notifUC.NotificationRecordStorage,
) *controller.NotificationController {
nc := controller.NewNotificationController(dispatcher, registry, channelStore, prefStore, recordStore)
notif := apiAuthRoutes.Group("/notifications")
// Channel types (advertised by the registry).
notif.GET("/channel-types", nc.ListChannelTypes)
// Channels
channels := notif.Group("/channels")
channels.GET("", nc.ListChannels)
channels.POST("", nc.CreateChannel)
channelID := channels.Group("/:channelId", middleware.NotificationChannelHandler(channelStore))
channelID.GET("", nc.GetChannel)
channelID.PUT("", nc.UpdateChannel)
channelID.DELETE("", nc.DeleteChannel)
// Rate-limit per user: each test triggers an outbound request and channels are user-owned.
testRLStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
Rate: time.Minute,
Limit: 5,
})
testRLMiddleware := ratelimit.RateLimiter(testRLStore, &ratelimit.Options{
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, happydns.ErrorResponse{
Message: "Too many test notifications. Please try again later.",
})
},
KeyFunc: func(c *gin.Context) string {
user := middleware.MyUser(c)
if user == nil {
return c.ClientIP()
}
return user.Id.String()
},
})
channelID.POST("/test", testRLMiddleware, nc.TestChannel)
// Preferences
prefs := notif.Group("/preferences")
prefs.GET("", nc.ListPreferences)
prefs.POST("", nc.CreatePreference)
prefID := prefs.Group("/:prefId", middleware.NotificationPreferenceHandler(prefStore))
prefID.GET("", nc.GetPreference)
prefID.PUT("", nc.UpdatePreference)
prefID.DELETE("", nc.DeletePreference)
// History
notif.GET("/history", nc.ListHistory)
return nc
}

View file

@ -31,7 +31,9 @@ import (
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
notifPkg "git.happydns.org/happyDomain/internal/notification"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
happydns "git.happydns.org/happyDomain/model"
)
@ -67,6 +69,12 @@ type Dependencies struct {
PlannedProvider checkerUC.PlannedJobProvider
BudgetChecker checkerUC.BudgetChecker
CountManualTriggers bool
NotificationDispatcher *notifUC.Dispatcher
NotificationRegistry *notifPkg.Registry
NotificationChannels notifUC.NotificationChannelStorage
NotificationPrefs notifUC.NotificationPreferenceStorage
NotificationRecords notifUC.NotificationRecordStorage
}
// @title happyDomain API
@ -154,6 +162,19 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
)
}
// Initialize notification controller if dispatcher is available.
var nc *controller.NotificationController
if dep.NotificationDispatcher != nil {
nc = DeclareNotificationRoutes(
apiAuthRoutes,
dep.NotificationDispatcher,
dep.NotificationRegistry,
dep.NotificationChannels,
dep.NotificationPrefs,
dep.NotificationRecords,
)
}
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(
apiAuthRoutes,
@ -168,6 +189,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
cc,
dep.CheckStatusUC,
dep.DomainInfo,
nc,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)

View file

@ -37,6 +37,7 @@ func DeclareZoneServiceRoutes(
serviceUC happydns.ServiceUsecase,
zoneUC happydns.ZoneUsecase,
cc *controller.CheckerController,
nc *controller.NotificationController,
) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
@ -51,6 +52,6 @@ func DeclareZoneServiceRoutes(
// Mount service-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc, nc)
}
}

View file

@ -38,6 +38,7 @@ func DeclareZoneRoutes(
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
nc *controller.NotificationController,
) {
var checkStatusUC *checkerUC.CheckStatusUsecase
if cc != nil {
@ -74,6 +75,7 @@ func DeclareZoneRoutes(
serviceUC,
zoneUC,
cc,
nc,
)
apiZonesRoutes.POST("/records", zc.AddRecords)

View file

@ -35,6 +35,7 @@ import (
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/newsletter"
notifPkg "git.happydns.org/happyDomain/internal/notification"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
@ -43,6 +44,7 @@ import (
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
emailAutoconfigUC "git.happydns.org/happyDomain/internal/usecase/emailautoconfig"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
@ -84,6 +86,9 @@ type Usecases struct {
checkerScheduler *checkerUC.Scheduler
checkerJanitor *checkerUC.Janitor
checkerUserGater *checkerUC.UserGater
notificationDispatcher *notifUC.Dispatcher
notificationRegistry *notifPkg.Registry
}
type App struct {
@ -331,6 +336,33 @@ func (app *App) initUsecases() {
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
// Notification system: dispatcher fans out checker results to user
// channels (email/webhook/UnifiedPush) based on per-target preferences.
baseURL := app.cfg.GetBaseURL()
registry := notifPkg.NewRegistry()
registry.Register(notifPkg.Adapt(notifPkg.NewEmailSender(app.mailer, baseURL)))
registry.Register(notifPkg.Adapt(notifPkg.NewWebhookSender(baseURL)))
registry.Register(notifPkg.Adapt(notifPkg.NewUnifiedPushSender(baseURL)))
app.usecases.notificationRegistry = registry
resolver := notifUC.NewResolver(app.store, app.store)
pool := notifUC.NewPool(registry, app.store)
tester := notifUC.NewTester(registry)
stateLocker := notifUC.NewStateLocker()
ack := notifUC.NewAckService(app.store, stateLocker)
app.usecases.notificationDispatcher = notifUC.NewDispatcher(
app.store,
app.store,
app.store,
resolver,
pool,
tester,
ack,
stateLocker,
)
if cb, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok {
cb.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete)
}
}
func (app *App) setupRouter() {
@ -386,6 +418,12 @@ func (app *App) setupRouter() {
PlannedProvider: app.usecases.checkerScheduler,
BudgetChecker: app.usecases.checkerUserGater,
CountManualTriggers: app.cfg.CheckerCountManualTriggers,
NotificationDispatcher: app.usecases.notificationDispatcher,
NotificationRegistry: app.usecases.notificationRegistry,
NotificationChannels: app.store,
NotificationPrefs: app.store,
NotificationRecords: app.store,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
@ -428,6 +466,10 @@ func (app *App) Start() {
app.usecases.checkerUserGater.Start(context.Background())
}
if app.usecases.notificationDispatcher != nil {
app.usecases.notificationDispatcher.Start()
}
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
@ -450,6 +492,12 @@ func (app *App) Stop() {
app.usecases.checkerUserGater.Stop()
}
// Drain in-flight notification sends after the scheduler is stopped
// so no new jobs can be enqueued while we wait.
if app.usecases.notificationDispatcher != nil {
app.usecases.notificationDispatcher.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.srv.Shutdown(ctx); err != nil {

View file

@ -137,6 +137,11 @@ func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error
return s.inner.CreateAuthUser(user)
}
func (s *instrumentedStorage) CreateChannel(ch *happydns.NotificationChannel) (err error) {
defer observe("create", "notification_channel")(&err)
return s.inner.CreateChannel(ch)
}
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("create", "check_plan")(&err)
return s.inner.CreateCheckPlan(plan)
@ -167,11 +172,21 @@ func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error
return s.inner.CreateOrUpdateUser(user)
}
func (s *instrumentedStorage) CreatePreference(pref *happydns.NotificationPreference) (err error) {
defer observe("create", "notification_preference")(&err)
return s.inner.CreatePreference(pref)
}
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
defer observe("create", "provider")(&err)
return s.inner.CreateProvider(prvd)
}
func (s *instrumentedStorage) CreateRecord(rec *happydns.NotificationRecord) (err error) {
defer observe("create", "notification_record")(&err)
return s.inner.CreateRecord(rec)
}
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
defer observe("create", "observation_snapshot")(&err)
return s.inner.CreateSnapshot(snap)
@ -187,6 +202,11 @@ func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error
return s.inner.DeleteAuthUser(user)
}
func (s *instrumentedStorage) DeleteChannel(channelId happydns.Identifier) (err error) {
defer observe("delete", "notification_channel")(&err)
return s.inner.DeleteChannel(channelId)
}
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.DeleteCheckPlan(planID)
@ -237,11 +257,21 @@ func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target
return s.inner.DeleteExecutionsByChecker(checkerID, target)
}
func (s *instrumentedStorage) DeletePreference(prefId happydns.Identifier) (err error) {
defer observe("delete", "notification_preference")(&err)
return s.inner.DeletePreference(prefId)
}
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
defer observe("delete", "provider")(&err)
return s.inner.DeleteProvider(prvdid)
}
func (s *instrumentedStorage) DeleteRecordsOlderThan(before time.Time) (err error) {
defer observe("delete", "notification_record")(&err)
return s.inner.DeleteRecordsOlderThan(before)
}
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
defer observe("delete", "session")(&err)
return s.inner.DeleteSession(sessionid)
@ -252,6 +282,11 @@ func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err er
return s.inner.DeleteSnapshot(snapID)
}
func (s *instrumentedStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (err error) {
defer observe("delete", "notification_state")(&err)
return s.inner.DeleteState(checkerID, target, userId)
}
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
defer observe("delete", "user")(&err)
return s.inner.DeleteUser(userid)
@ -282,6 +317,11 @@ func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget,
return s.inner.GetCachedObservation(target, key)
}
func (s *instrumentedStorage) GetChannel(channelId happydns.Identifier) (ret *happydns.NotificationChannel, err error) {
defer observe("get", "notification_channel")(&err)
return s.inner.GetChannel(channelId)
}
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
defer observe("get", "check_plan")(&err)
return s.inner.GetCheckPlan(planID)
@ -322,6 +362,11 @@ func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (r
return s.inner.GetLatestEvaluation(planID)
}
func (s *instrumentedStorage) GetPreference(prefId happydns.Identifier) (ret *happydns.NotificationPreference, err error) {
defer observe("get", "notification_preference")(&err)
return s.inner.GetPreference(prefId)
}
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
defer observe("get", "provider")(&err)
return s.inner.GetProvider(prvdid)
@ -337,6 +382,11 @@ func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happ
return s.inner.GetSnapshot(snapID)
}
func (s *instrumentedStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (ret *happydns.NotificationState, err error) {
defer observe("get", "notification_state")(&err)
return s.inner.GetState(checkerID, target, userId)
}
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
defer observe("get", "user")(&err)
return s.inner.GetUser(userid)
@ -447,6 +497,11 @@ func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret
return s.inner.ListAuthUserSessions(user)
}
func (s *instrumentedStorage) ListChannelsByUser(userId happydns.Identifier) (ret []*happydns.NotificationChannel, err error) {
defer observe("list", "notification_channel")(&err)
return s.inner.ListChannelsByUser(userId)
}
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByChecker(checkerID)
@ -522,11 +577,26 @@ func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, l
return s.inner.ListExecutionsByUser(userId, limit, filter)
}
func (s *instrumentedStorage) ListPreferencesByUser(userId happydns.Identifier) (ret []*happydns.NotificationPreference, err error) {
defer observe("list", "notification_preference")(&err)
return s.inner.ListPreferencesByUser(userId)
}
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
defer observe("list", "provider")(&err)
return s.inner.ListProviders(user)
}
func (s *instrumentedStorage) ListRecordsByUser(userId happydns.Identifier, limit int) (ret []*happydns.NotificationRecord, err error) {
defer observe("list", "notification_record")(&err)
return s.inner.ListRecordsByUser(userId, limit)
}
func (s *instrumentedStorage) ListStatesByUser(userId happydns.Identifier) (ret []*happydns.NotificationState, err error) {
defer observe("list", "notification_state")(&err)
return s.inner.ListStatesByUser(userId)
}
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
defer observe("list", "session")(&err)
return s.inner.ListUserSessions(userid)
@ -544,6 +614,11 @@ func (s *instrumentedStorage) PutDiscoveryObservationRef(ref *happydns.Discovery
return s.inner.PutDiscoveryObservationRef(ref)
}
func (s *instrumentedStorage) PutState(state *happydns.NotificationState) (err error) {
defer observe("put", "notification_state")(&err)
return s.inner.PutState(state)
}
func (s *instrumentedStorage) ReplaceDiscoveryEntries(producerID string, target happydns.CheckTarget, entries []happydns.DiscoveryEntry) (err error) {
defer observe("update", "discovery_entry")(&err)
return s.inner.ReplaceDiscoveryEntries(producerID, target, entries)
@ -601,6 +676,11 @@ func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error
return s.inner.UpdateAuthUser(user)
}
func (s *instrumentedStorage) UpdateChannel(ch *happydns.NotificationChannel) (err error) {
defer observe("update", "notification_channel")(&err)
return s.inner.UpdateChannel(ch)
}
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("update", "check_plan")(&err)
return s.inner.UpdateCheckPlan(plan)
@ -626,6 +706,11 @@ func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err err
return s.inner.UpdateExecution(exec)
}
func (s *instrumentedStorage) UpdatePreference(pref *happydns.NotificationPreference) (err error) {
defer observe("update", "notification_preference")(&err)
return s.inner.UpdatePreference(pref)
}
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
defer observe("update", "provider")(&err)
return s.inner.UpdateProvider(prvd)

View file

@ -0,0 +1,110 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
"git.happydns.org/happyDomain/model"
)
const ChannelTypeEmail happydns.NotificationChannelType = "email"
type EmailConfig struct {
// Empty means fall back to the user's account email.
Address string `json:"address,omitempty"`
}
func (c EmailConfig) Validate() error {
if c.Address == "" {
return nil
}
if _, err := mail.ParseAddress(c.Address); err != nil {
return fmt.Errorf("invalid email address: %w", err)
}
return nil
}
// baseURL is captured here — server identity, not per-notification data.
type EmailSender struct {
mailer happydns.Mailer
baseURL string
}
func NewEmailSender(mailer happydns.Mailer, baseURL string) *EmailSender {
return &EmailSender{mailer: mailer, baseURL: baseURL}
}
func (s *EmailSender) Type() happydns.NotificationChannelType { return ChannelTypeEmail }
func (s *EmailSender) Send(_ context.Context, c EmailConfig, payload *NotificationPayload) error {
addr := c.Address
if addr == "" {
addr = payload.Recipient.Email
}
if addr == "" {
return errors.New("no email address available")
}
to := &mail.Address{Address: addr}
// Strip CR/LF to prevent RFC 5322 header injection.
safeDomain := stripCRLF(payload.DomainName)
subject := fmt.Sprintf("[happyDomain] %s: %s", safeDomain, payload.NewStatus)
// Wrap third-party-sourced fields as Markdown code spans to neutralize injected link syntax in DKIM-signed mail; Annotation is user-authored, no boundary.
var body strings.Builder
fmt.Fprintf(&body, "## Status Change: %s -> %s\n\n", payload.OldStatus, payload.NewStatus)
fmt.Fprintf(&body, "**Domain:** %s\n\n", mdLiteral(payload.DomainName))
fmt.Fprintf(&body, "**Checker:** %s\n\n", mdLiteral(payload.CheckerID))
if len(payload.States) > 0 {
body.WriteString("### Rule Results\n\n")
for _, state := range payload.States {
fmt.Fprintf(&body, "- %s (%s): %s\n", mdLiteral(state.Code), state.Status, mdLiteral(state.Message))
}
body.WriteString("\n")
}
if payload.Annotation != "" {
fmt.Fprintf(&body, "**Note:** %s\n\n", payload.Annotation)
}
if s.baseURL != "" {
fmt.Fprintf(&body, "[View in happyDomain](%s)\n", s.baseURL)
}
return s.mailer.SendMail(to, subject, body.String())
}
func stripCRLF(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
// Wraps s as a code span; backticks become apostrophes to avoid fence accounting.
func mdLiteral(s string) string {
return "`" + strings.ReplaceAll(s, "`", "'") + "`"
}

View file

@ -0,0 +1,101 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
// Shared by both webhook and UnifiedPush.
type httpJSONPayload struct {
Event string `json:"event"`
Checker string `json:"checker"`
Domain string `json:"domain"`
Target happydns.CheckTarget `json:"target"`
OldStatus happydns.Status `json:"oldStatus"`
NewStatus happydns.Status `json:"newStatus"`
States []happydns.CheckState `json:"states,omitempty"`
Timestamp time.Time `json:"timestamp"`
DashboardURL string `json:"dashboardUrl,omitempty"`
}
func buildHTTPPayload(p *NotificationPayload, dashboardURL string) httpJSONPayload {
return httpJSONPayload{
Event: "status_change",
Checker: p.CheckerID,
Domain: p.DomainName,
Target: p.Target,
OldStatus: p.OldStatus,
NewStatus: p.NewStatus,
States: p.States,
Timestamp: time.Now(),
DashboardURL: dashboardURL,
}
}
// decorate runs after marshal so it can sign the exact bytes (e.g. HMAC).
func postJSON(ctx context.Context, client *http.Client, url string, body any, decorate func(*http.Request, []byte)) error {
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshaling payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if decorate != nil {
decorate(req, raw)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBodyBytes))
if resp.StatusCode >= 300 {
return fmt.Errorf("endpoint returned status %d", resp.StatusCode)
}
return nil
}
func testPayload(rcpt Recipient) *NotificationPayload {
return &NotificationPayload{
Recipient: rcpt,
CheckerID: "test",
DomainName: "example.com",
OldStatus: happydns.StatusOK,
NewStatus: happydns.StatusWarn,
Annotation: "This is a test notification from happyDomain.",
}
}

View file

@ -0,0 +1,132 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
// We drain the response only for keep-alive; body is unused.
const maxResponseBodyBytes = 64 * 1024
var errBlockedAddress = errors.New("address resolves to a blocked range")
// Reject non-http(s), missing host, or private/loopback IP literals; DNS hosts re-checked at dial time.
func validateOutboundURL(rawURL string) (*url.URL, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("unsupported scheme %q (only http and https are allowed)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return nil, errors.New("URL has no host")
}
if ip := net.ParseIP(host); ip != nil {
if !isPublicIP(ip) {
return nil, errBlockedAddress
}
}
return u, nil
}
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsPrivate() || ip.IsInterfaceLocalMulticast() {
return false
}
// Block IPv4 broadcast 255.255.255.255 explicitly (not covered above).
if v4 := ip.To4(); v4 != nil && v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255 {
return false
}
return true
}
// Re-check resolved IP to defeat DNS rebinding.
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
resolver := net.DefaultResolver
ips, err := resolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if !isPublicIP(ip) {
return nil, fmt.Errorf("dial %s: %w", address, errBlockedAddress)
}
}
// Pin the dial to a validated IP rather than letting the dialer re-resolve.
var lastErr error
for _, ip := range ips {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
if errors.Is(err, syscall.ECONNREFUSED) {
continue
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("dial %s: no usable address", address)
}
// Refuses private/loopback addresses and re-validates each redirect hop.
func newSafeHTTPClient(timeout time.Duration) *http.Client {
transport := &http.Transport{
DialContext: safeDialContext,
ResponseHeaderTimeout: timeout,
IdleConnTimeout: 30 * time.Second,
}
return &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
if _, err := validateOutboundURL(req.URL.String()); err != nil {
return err
}
return nil
},
}
}

View file

@ -0,0 +1,231 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"encoding/json"
"errors"
"fmt"
"git.happydns.org/happyDomain/model"
)
// Carries only what at least one transport needs, so other transports cannot leak the user record.
type Recipient struct {
// May be empty for transports that don't need it (webhook, UnifiedPush).
Email string
}
// Senders receive only render-needed data — no user object, no server config — so adding a transport cannot leak privileged data.
type NotificationPayload struct {
Recipient Recipient
CheckerID string
Target happydns.CheckTarget
DomainName string
OldStatus happydns.Status
NewStatus happydns.Status
States []happydns.CheckState
Annotation string
}
type ChannelConfig interface {
Validate() error
}
// Senders own their config shape so adding a transport is a one-file change.
// Most implementations should embed TypedSender[C] via Adapt rather than implementing this directly.
type ChannelSender interface {
Type() happydns.NotificationChannelType
DecodeConfig(raw json.RawMessage) (ChannelConfig, error)
Send(ctx context.Context, cfg ChannelConfig, payload *NotificationPayload) error
SendTest(ctx context.Context, cfg ChannelConfig, user *happydns.User) error
// Strip secrets to presence booleans before echoing config back to clients.
RedactConfig(raw json.RawMessage) (json.RawMessage, error)
// Preserve stored secrets when client submits empty fields (client never sees them on read).
MergeForUpdate(existing, incoming json.RawMessage) (json.RawMessage, error)
}
// Optional capability: senders with secret fields opt in by implementing this on their TypedSender.
type ConfigRedactor[C ChannelConfig] interface {
RedactConfig(cfg C) C
}
type ConfigMerger[C ChannelConfig] interface {
MergeForUpdate(existing, incoming C) C
}
// Strongly-typed contract; Adapt wraps it as ChannelSender, providing JSON decode, validation, type-asserted dispatch, and SendTest.
type TypedSender[C ChannelConfig] interface {
Type() happydns.NotificationChannelType
Send(ctx context.Context, cfg C, payload *NotificationPayload) error
}
func Adapt[C ChannelConfig](s TypedSender[C]) ChannelSender {
return &typedAdapter[C]{inner: s}
}
type typedAdapter[C ChannelConfig] struct {
inner TypedSender[C]
}
func (a *typedAdapter[C]) Type() happydns.NotificationChannelType { return a.inner.Type() }
func (a *typedAdapter[C]) DecodeConfig(raw json.RawMessage) (ChannelConfig, error) {
var c C
if len(raw) > 0 {
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err)
}
}
if err := c.Validate(); err != nil {
return nil, err
}
return c, nil
}
func (a *typedAdapter[C]) Send(ctx context.Context, cfg ChannelConfig, payload *NotificationPayload) error {
typed, ok := cfg.(C)
if !ok {
return fmt.Errorf("%s sender: unexpected config type %T", a.inner.Type(), cfg)
}
return a.inner.Send(ctx, typed, payload)
}
func (a *typedAdapter[C]) SendTest(ctx context.Context, cfg ChannelConfig, user *happydns.User) error {
return a.Send(ctx, cfg, testPayload(Recipient{Email: user.Email}))
}
func (a *typedAdapter[C]) RedactConfig(raw json.RawMessage) (json.RawMessage, error) {
redactor, ok := a.inner.(ConfigRedactor[C])
if !ok {
return raw, nil
}
var c C
if len(raw) > 0 {
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err)
}
}
c = redactor.RedactConfig(c)
return json.Marshal(c)
}
func (a *typedAdapter[C]) MergeForUpdate(existing, incoming json.RawMessage) (json.RawMessage, error) {
merger, ok := a.inner.(ConfigMerger[C])
if !ok {
return incoming, nil
}
var ec, ic C
if len(existing) > 0 {
if err := json.Unmarshal(existing, &ec); err != nil {
return nil, fmt.Errorf("decoding existing %s config: %w", a.inner.Type(), err)
}
}
if len(incoming) > 0 {
if err := json.Unmarshal(incoming, &ic); err != nil {
return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err)
}
}
return json.Marshal(merger.MergeForUpdate(ec, ic))
}
// Senders self-register at startup; adding a transport requires no changes here.
type Registry struct {
senders map[happydns.NotificationChannelType]ChannelSender
}
func NewRegistry() *Registry {
return &Registry{senders: make(map[happydns.NotificationChannelType]ChannelSender)}
}
// Panics on duplicate — programming error.
func (r *Registry) Register(s ChannelSender) {
t := s.Type()
if _, exists := r.senders[t]; exists {
panic(fmt.Sprintf("notification: sender already registered for type %q", t))
}
r.senders[t] = s
}
func (r *Registry) Get(t happydns.NotificationChannelType) (ChannelSender, bool) {
s, ok := r.senders[t]
return s, ok
}
func (r *Registry) Types() []happydns.NotificationChannelType {
out := make([]happydns.NotificationChannelType, 0, len(r.senders))
for t := range r.senders {
out = append(out, t)
}
return out
}
func (r *Registry) DecodeChannelConfig(ch *happydns.NotificationChannel) (ChannelConfig, error) {
s, ok := r.Get(ch.Type)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownChannelType, ch.Type)
}
return s.DecodeConfig(ch.Config)
}
// Channels of unknown types are returned unchanged so administrators can still observe legacy data.
func (r *Registry) RedactChannel(ch *happydns.NotificationChannel) (*happydns.NotificationChannel, error) {
if ch == nil {
return nil, nil
}
s, ok := r.Get(ch.Type)
if !ok {
copy := *ch
return &copy, nil
}
redacted, err := s.RedactConfig(ch.Config)
if err != nil {
return nil, err
}
copy := *ch
copy.Config = redacted
return &copy, nil
}
func (r *Registry) RedactChannels(chs []*happydns.NotificationChannel) ([]*happydns.NotificationChannel, error) {
out := make([]*happydns.NotificationChannel, 0, len(chs))
for _, ch := range chs {
red, err := r.RedactChannel(ch)
if err != nil {
return nil, err
}
out = append(out, red)
}
return out, nil
}
// Caller should DecodeConfig the returned raw before persisting.
func (r *Registry) MergeChannelForUpdate(existing, incoming *happydns.NotificationChannel) (json.RawMessage, error) {
s, ok := r.Get(incoming.Type)
if !ok {
return incoming.Config, nil
}
return s.MergeForUpdate(existing.Config, incoming.Config)
}
var ErrUnknownChannelType = errors.New("unknown channel type")

View file

@ -0,0 +1,67 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
const ChannelTypeUnifiedPush happydns.NotificationChannelType = "unifiedpush"
type UnifiedPushConfig struct {
Endpoint string `json:"endpoint"`
}
func (c UnifiedPushConfig) Validate() error {
if c.Endpoint == "" {
return errors.New("UnifiedPush endpoint is required")
}
if _, err := validateOutboundURL(c.Endpoint); err != nil {
return fmt.Errorf("UnifiedPush endpoint: %w", err)
}
return nil
}
// dashboardURL is captured here — server identity, not per-notification data.
type UnifiedPushSender struct {
client *http.Client
dashboardURL string
}
func NewUnifiedPushSender(dashboardURL string) *UnifiedPushSender {
return &UnifiedPushSender{
client: newSafeHTTPClient(10 * time.Second),
dashboardURL: dashboardURL,
}
}
func (s *UnifiedPushSender) Type() happydns.NotificationChannelType { return ChannelTypeUnifiedPush }
func (s *UnifiedPushSender) Send(ctx context.Context, c UnifiedPushConfig, payload *NotificationPayload) error {
return postJSON(ctx, s.client, c.Endpoint, buildHTTPPayload(payload, s.dashboardURL), nil)
}

View file

@ -0,0 +1,135 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
// Reserved by the HTTP client or used to spoof outbound identity (smuggling/host-routing risk).
var disallowedWebhookHeaders = map[string]struct{}{
"host": {},
"content-length": {},
"content-encoding": {},
"transfer-encoding": {},
"connection": {},
"upgrade": {},
"te": {},
"trailer": {},
}
func validateHeader(k, v string) error {
if k == "" {
return errors.New("empty header name")
}
if strings.ContainsAny(k, "\r\n") || strings.ContainsAny(v, "\r\n") {
return fmt.Errorf("header %q contains CR/LF", k)
}
if _, blocked := disallowedWebhookHeaders[strings.ToLower(k)]; blocked {
return fmt.Errorf("header %q is not allowed", k)
}
return nil
}
const ChannelTypeWebhook happydns.NotificationChannelType = "webhook"
type WebhookConfig struct {
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
// HMAC-SHA256 signing key.
Secret string `json:"secret,omitempty"`
// Set only by RedactConfig — never stored or accepted on input.
HasSecret bool `json:"hasSecret,omitempty"`
}
func (c WebhookConfig) Validate() error {
if c.URL == "" {
return errors.New("webhook URL is required")
}
if _, err := validateOutboundURL(c.URL); err != nil {
return fmt.Errorf("webhook URL: %w", err)
}
for k, v := range c.Headers {
if err := validateHeader(k, v); err != nil {
return fmt.Errorf("webhook header: %w", err)
}
}
return nil
}
// dashboardURL is captured here — server identity, not per-notification data.
type WebhookSender struct {
client *http.Client
dashboardURL string
}
func NewWebhookSender(dashboardURL string) *WebhookSender {
return &WebhookSender{
client: newSafeHTTPClient(10 * time.Second),
dashboardURL: dashboardURL,
}
}
func (s *WebhookSender) Type() happydns.NotificationChannelType { return ChannelTypeWebhook }
func (s *WebhookSender) RedactConfig(cfg WebhookConfig) WebhookConfig {
cfg.HasSecret = cfg.Secret != ""
cfg.Secret = ""
return cfg
}
// Preserve stored secret on empty submit; client never receives it back, so absence means "no change".
func (s *WebhookSender) MergeForUpdate(existing, incoming WebhookConfig) WebhookConfig {
if incoming.Secret == "" {
incoming.Secret = existing.Secret
}
incoming.HasSecret = false
return incoming
}
func (s *WebhookSender) Send(ctx context.Context, c WebhookConfig, payload *NotificationPayload) error {
return postJSON(ctx, s.client, c.URL, buildHTTPPayload(payload, s.dashboardURL), func(req *http.Request, body []byte) {
req.Header.Set("User-Agent", "happyDomain-Notification/1.0")
for k, v := range c.Headers {
// Defense in depth: catches stored channels that pre-date Validate().
if err := validateHeader(k, v); err != nil {
continue
}
req.Header.Set(k, v)
}
if c.Secret != "" {
mac := hmac.New(sha256.New, []byte(c.Secret))
mac.Write(body)
req.Header.Set("X-Happydomain-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
})
}

View file

@ -27,6 +27,7 @@ import (
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
"git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/internal/usecase/user"
@ -53,6 +54,10 @@ type Storage interface {
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage
notification.NotificationChannelStorage
notification.NotificationPreferenceStorage
notification.NotificationStateStorage
notification.NotificationRecordStorage
provider.ProviderStorage
session.SessionStorage
user.UserStorage

View file

@ -0,0 +1,136 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"log"
"strings"
"git.happydns.org/happyDomain/model"
)
// Layout: notifch|<channelId> -> full record; notifch-user|<userId>|<channelId> -> "" (index only, no double-write).
const (
notifchPrimaryPrefix = "notifch|"
notifchUserPrefix = "notifch-user|"
)
func notifchPrimaryKey(id happydns.Identifier) string {
return notifchPrimaryPrefix + id.String()
}
func notifchUserKey(userId, channelId happydns.Identifier) string {
return fmt.Sprintf("%s%s|%s", notifchUserPrefix, userId.String(), channelId.String())
}
func channelIdFromUserIndexKey(key string) (string, bool) {
rest, ok := strings.CutPrefix(key, notifchUserPrefix)
if !ok {
return "", false
}
_, channelId, ok := strings.Cut(rest, "|")
if !ok || channelId == "" {
return "", false
}
return channelId, true
}
func (s *KVStorage) ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error) {
prefix := notifchUserPrefix + userId.String() + "|"
iter := s.db.Search(prefix)
defer iter.Release()
var channels []*happydns.NotificationChannel
for iter.Next() {
idStr, ok := channelIdFromUserIndexKey(iter.Key())
if !ok {
continue
}
id, err := happydns.NewIdentifierFromString(idStr)
if err != nil {
log.Printf("storage: malformed channel index key %q: %v", iter.Key(), err)
continue
}
ch, err := s.GetChannel(id)
if err != nil {
// Index drift: skip rather than fail the whole list.
log.Printf("storage: channel index points to missing channel %q: %v", idStr, err)
continue
}
channels = append(channels, ch)
}
return channels, nil
}
func (s *KVStorage) GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error) {
ch := &happydns.NotificationChannel{}
err := s.db.Get(notifchPrimaryKey(channelId), ch)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrNotificationChannelNotFound
}
return ch, err
}
func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
key, id, err := s.db.FindIdentifierKey(notifchPrimaryPrefix)
if err != nil {
return err
}
ch.Id = id
if err := s.db.Put(key, ch); err != nil {
return err
}
if err := s.db.Put(notifchUserKey(ch.UserId, ch.Id), ""); err != nil {
// Roll back primary so a failed index write doesn't orphan it.
if delErr := s.db.Delete(key); delErr != nil {
log.Printf("storage: orphan channel %q after index write failed (rollback also failed: %v)", ch.Id.String(), delErr)
}
return err
}
return nil
}
func (s *KVStorage) UpdateChannel(ch *happydns.NotificationChannel) error {
// Index has no payload, so only the primary needs writing.
return s.db.Put(notifchPrimaryKey(ch.Id), ch)
}
func (s *KVStorage) DeleteChannel(channelId happydns.Identifier) error {
ch, err := s.GetChannel(channelId)
if err != nil {
return err
}
// Delete index first so partial failure hides the channel rather than leaving it visible-but-broken.
if err := s.db.Delete(notifchUserKey(ch.UserId, channelId)); err != nil {
return err
}
if err := s.db.Delete(notifchPrimaryKey(channelId)); err != nil {
log.Printf("storage: channel %q index removed but primary delete failed: %v", channelId.String(), err)
return err
}
return nil
}

View file

@ -0,0 +1,132 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"log"
"strings"
"git.happydns.org/happyDomain/model"
)
// Same primary+per-user-index layout as notification_channel.go.
const (
notifprefPrimaryPrefix = "notifpref|"
notifprefUserPrefix = "notifpref-user|"
)
func notifprefPrimaryKey(id happydns.Identifier) string {
return notifprefPrimaryPrefix + id.String()
}
func notifprefUserKey(userId, prefId happydns.Identifier) string {
return fmt.Sprintf("%s%s|%s", notifprefUserPrefix, userId.String(), prefId.String())
}
func prefIdFromUserIndexKey(key string) (string, bool) {
rest, ok := strings.CutPrefix(key, notifprefUserPrefix)
if !ok {
return "", false
}
_, prefId, ok := strings.Cut(rest, "|")
if !ok || prefId == "" {
return "", false
}
return prefId, true
}
func (s *KVStorage) ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error) {
prefix := notifprefUserPrefix + userId.String() + "|"
iter := s.db.Search(prefix)
defer iter.Release()
var prefs []*happydns.NotificationPreference
for iter.Next() {
idStr, ok := prefIdFromUserIndexKey(iter.Key())
if !ok {
continue
}
id, err := happydns.NewIdentifierFromString(idStr)
if err != nil {
log.Printf("storage: malformed preference index key %q: %v", iter.Key(), err)
continue
}
pref, err := s.GetPreference(id)
if err != nil {
log.Printf("storage: preference index points to missing preference %q: %v", idStr, err)
continue
}
prefs = append(prefs, pref)
}
return prefs, nil
}
func (s *KVStorage) GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error) {
pref := &happydns.NotificationPreference{}
err := s.db.Get(notifprefPrimaryKey(prefId), pref)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrNotificationPreferenceNotFound
}
return pref, err
}
func (s *KVStorage) CreatePreference(pref *happydns.NotificationPreference) error {
key, id, err := s.db.FindIdentifierKey(notifprefPrimaryPrefix)
if err != nil {
return err
}
pref.Id = id
if err := s.db.Put(key, pref); err != nil {
return err
}
if err := s.db.Put(notifprefUserKey(pref.UserId, pref.Id), ""); err != nil {
if delErr := s.db.Delete(key); delErr != nil {
log.Printf("storage: orphan preference %q after index write failed (rollback also failed: %v)", pref.Id.String(), delErr)
}
return err
}
return nil
}
func (s *KVStorage) UpdatePreference(pref *happydns.NotificationPreference) error {
return s.db.Put(notifprefPrimaryKey(pref.Id), pref)
}
func (s *KVStorage) DeletePreference(prefId happydns.Identifier) error {
pref, err := s.GetPreference(prefId)
if err != nil {
return err
}
if err := s.db.Delete(notifprefUserKey(pref.UserId, prefId)); err != nil {
return err
}
if err := s.db.Delete(notifprefPrimaryKey(prefId)); err != nil {
log.Printf("storage: preference %q index removed but primary delete failed: %v", prefId.String(), err)
return err
}
return nil
}

View file

@ -0,0 +1,97 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"log"
"sort"
"time"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) CreateRecord(rec *happydns.NotificationRecord) error {
key, id, err := s.db.FindIdentifierKey("notifrec|")
if err != nil {
return err
}
rec.Id = id
if err := s.db.Put(key, rec); err != nil {
return err
}
indexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
return s.db.Put(indexKey, rec)
}
func (s *KVStorage) ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error) {
prefix := fmt.Sprintf("notifrec-user|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var records []*happydns.NotificationRecord
for iter.Next() {
var rec happydns.NotificationRecord
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
// Corrupt entry: log and skip rather than fail the whole listing.
log.Printf("storage: malformed notification record at %q: %v", iter.Key(), err)
continue
}
records = append(records, &rec)
}
sort.Slice(records, func(i, j int) bool {
return records[i].SentAt.After(records[j].SentAt)
})
if limit > 0 && len(records) > limit {
records = records[:limit]
}
return records, nil
}
func (s *KVStorage) DeleteRecordsOlderThan(before time.Time) error {
iter := s.db.Search("notifrec|")
defer iter.Release()
var errs []error
for iter.Next() {
var rec happydns.NotificationRecord
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
log.Printf("storage: malformed notification record at %q: %v", iter.Key(), err)
continue
}
if rec.SentAt.Before(before) {
if err := s.db.Delete(iter.Key()); err != nil {
errs = append(errs, fmt.Errorf("delete %s: %w", iter.Key(), err))
}
userIndexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
if err := s.db.Delete(userIndexKey); err != nil {
errs = append(errs, fmt.Errorf("delete %s: %w", userIndexKey, err))
}
}
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,76 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"log"
"git.happydns.org/happyDomain/model"
)
// Built from explicit fields so SDK changes to CheckTarget can't reshape the key and orphan stored records.
func notifStateKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
return fmt.Sprintf(
"notifstate|%s|%s|%s/%s/%s",
userId.String(),
checkerID,
target.UserId,
target.DomainId,
target.ServiceId,
)
}
func (s *KVStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error) {
state := &happydns.NotificationState{}
err := s.db.Get(notifStateKey(checkerID, target, userId), state)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrNotificationStateNotFound
}
return state, err
}
func (s *KVStorage) PutState(state *happydns.NotificationState) error {
return s.db.Put(notifStateKey(state.CheckerID, state.Target, state.UserId), state)
}
func (s *KVStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error {
return s.db.Delete(notifStateKey(checkerID, target, userId))
}
func (s *KVStorage) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
prefix := fmt.Sprintf("notifstate|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var states []*happydns.NotificationState
for iter.Next() {
var state happydns.NotificationState
if err := s.db.DecodeData(iter.Value(), &state); err != nil {
log.Printf("storage: malformed notification state at %q: %v", iter.Key(), err)
continue
}
states = append(states, &state)
}
return states, nil
}

View file

@ -0,0 +1,28 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
// migrateFrom10 adds the notification system tables.
// KV storage uses prefix-based keys, so no structural migration is needed.
func migrateFrom10(s *KVStorage) error {
return nil
}

View file

@ -39,6 +39,7 @@ var migrations []KVMigrationFunc = []KVMigrationFunc{
migrateFrom7,
migrateFrom8,
migrateFrom9,
migrateFrom10,
}
type Version struct {

View file

@ -26,12 +26,24 @@ import (
"encoding/json"
"fmt"
"log"
"sync/atomic"
"time"
checkerPkg "git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// executionCallback is the signature stored under onComplete. Wrapping it in a
// named type lets us hold it via atomic.Pointer without leaking the function
// type spelling everywhere.
type executionCallback func(*happydns.Execution, *happydns.CheckEvaluation)
// ExecutionCallbackSetter is implemented by checker engines that support
// notification callbacks after execution completion.
type ExecutionCallbackSetter interface {
SetExecutionCallback(func(*happydns.Execution, *happydns.CheckEvaluation))
}
// checkerEngine implements the happydns.CheckerEngine interface.
type checkerEngine struct {
optionsUC *CheckerOptionsUsecase
@ -42,6 +54,21 @@ type checkerEngine struct {
entryStore DiscoveryEntryStorage
obsRefStore DiscoveryObservationStorage
relatedLookup checkerPkg.RelatedObservationLookup
// onComplete is read concurrently by RunExecution from worker goroutines
// while SetExecutionCallback writes it during app wiring (and potentially
// later, defensively). atomic.Pointer keeps the load/store race-free.
onComplete atomic.Pointer[executionCallback]
}
// SetExecutionCallback registers a callback invoked after each successful execution.
func (e *checkerEngine) SetExecutionCallback(cb func(*happydns.Execution, *happydns.CheckEvaluation)) {
if cb == nil {
e.onComplete.Store(nil)
return
}
wrapped := executionCallback(cb)
e.onComplete.Store(&wrapped)
}
// NewCheckerEngine creates a new CheckerEngine implementation. Passing nil
@ -145,6 +172,13 @@ func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Executi
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
// Fire notification callback. The callback decides synchronously whether
// to notify and advances state, but actual sender invocations are
// dispatched to a worker pool so a slow channel cannot wedge the engine.
if cb := e.onComplete.Load(); cb != nil {
(*cb)(exec, eval)
}
return eval, nil
}

View file

@ -0,0 +1,93 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"time"
"git.happydns.org/happyDomain/model"
)
// Depends only on the state store — no senders, no preferences.
type AckService struct {
stateStore NotificationStateStorage
locker *StateLocker
// Overridable for tests.
nowFn func() time.Time
}
// locker is shared with the Dispatcher to avoid racing on the same state record.
func NewAckService(stateStore NotificationStateStorage, locker *StateLocker) *AckService {
return &AckService{stateStore: stateStore, locker: locker, nowFn: time.Now}
}
// An existing state record (created by the dispatcher when an execution
// completed) is required: acknowledging an issue that the dispatcher has
// never observed is rejected with ErrNotificationStateNotFound. This avoids
// letting an authenticated client materialize arbitrary state records by
// guessing checker IDs or target tuples.
func (a *AckService) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error {
unlock := a.locker.Lock(checkerID, target, userId)
defer unlock()
state, err := a.stateStore.GetState(checkerID, target, userId)
if err != nil {
return err
}
// Defense in depth: the storage key already encodes userId, but reject any
// state whose stored UserId disagrees rather than silently overwriting.
if !state.UserId.Equals(userId) {
return happydns.ErrNotificationStateNotFound
}
now := a.nowFn()
state.Acknowledged = true
state.AcknowledgedAt = &now
state.AcknowledgedBy = acknowledgedBy
state.Annotation = annotation
return a.stateStore.PutState(state)
}
func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
unlock := a.locker.Lock(checkerID, target, userId)
defer unlock()
state, err := a.stateStore.GetState(checkerID, target, userId)
if err != nil {
return err
}
if !state.UserId.Equals(userId) {
return happydns.ErrNotificationStateNotFound
}
state.ClearAcknowledgement()
return a.stateStore.PutState(state)
}
func (a *AckService) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
return a.stateStore.GetState(checkerID, target, userId)
}
func (a *AckService) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
return a.stateStore.ListStatesByUser(userId)
}

View file

@ -0,0 +1,209 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"errors"
"log"
"time"
notifPkg "git.happydns.org/happyDomain/internal/notification"
"git.happydns.org/happyDomain/model"
)
// Glue between checker execution and notification system; owns no I/O — all of it lives in collaborators.
type Dispatcher struct {
stateStore NotificationStateStorage
userStore UserGetter
domainStore DomainGetter
resolver *Resolver
pool *Pool
tester *Tester
ack *AckService
locker *StateLocker
// Overridable for tests.
nowFn func() time.Time
}
// Caller owns Pool lifecycle.
func NewDispatcher(
stateStore NotificationStateStorage,
userStore UserGetter,
domainStore DomainGetter,
resolver *Resolver,
pool *Pool,
tester *Tester,
ack *AckService,
locker *StateLocker,
) *Dispatcher {
return &Dispatcher{
stateStore: stateStore,
userStore: userStore,
domainStore: domainStore,
resolver: resolver,
pool: pool,
tester: tester,
ack: ack,
locker: locker,
nowFn: time.Now,
}
}
func (d *Dispatcher) Start() { d.pool.Start() }
func (d *Dispatcher) Stop() { d.pool.Stop() }
func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydns.CheckEvaluation) {
if exec == nil || exec.Status != happydns.ExecutionDone {
return
}
userId := happydns.TargetIdentifier(exec.Target.UserId)
if userId == nil {
return
}
user, err := d.userStore.GetUser(*userId)
if err != nil {
log.Printf("notification: failed to load user %q: %v", userId, err)
return
}
newStatus := exec.Result.Status
// Serialize with AckService so concurrent updates can't wipe an ack or fire duplicates.
unlock := d.locker.Lock(exec.CheckerID, exec.Target, *userId)
defer unlock()
state, err := d.loadOrInitState(exec, *userId)
if err != nil {
return
}
oldStatus := state.LastStatus
pref := d.resolver.ResolvePreference(user, exec.Target)
dec := decide(state, pref, newStatus, d.nowFn())
// Recovery/escalation invalidates ack: incident is over or has worsened.
if dec.ClearAck {
state.ClearAcknowledgement()
}
switch dec.Action {
case actionSkip:
return
case actionAdvance:
d.advanceState(state, newStatus)
return
}
payload := d.buildPayload(user, exec, eval, oldStatus, newStatus)
// Mark before enqueue so a rapid re-run sees oldStatus == newStatus and skips.
d.markNotified(state, newStatus)
for _, ch := range d.resolver.ResolveChannels(user, pref) {
d.pool.Enqueue(ch, payload, user)
}
}
func (d *Dispatcher) loadOrInitState(exec *happydns.Execution, userId happydns.Identifier) (*happydns.NotificationState, error) {
state, err := d.stateStore.GetState(exec.CheckerID, exec.Target, userId)
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
return &happydns.NotificationState{
CheckerID: exec.CheckerID,
Target: exec.Target,
UserId: userId,
LastStatus: happydns.StatusUnknown,
}, nil
}
if err != nil {
log.Printf("notification: failed to load state for %q/%q: %v", exec.CheckerID, exec.Target.String(), err)
return nil, err
}
return state, nil
}
func (d *Dispatcher) buildPayload(user *happydns.User, exec *happydns.Execution, eval *happydns.CheckEvaluation, oldStatus, newStatus happydns.Status) *notifPkg.NotificationPayload {
var domainName string
if did := happydns.TargetIdentifier(exec.Target.DomainId); did != nil {
if domain, err := d.domainStore.GetDomain(*did); err == nil {
domainName = domain.DomainName
}
}
if domainName == "" {
domainName = "(unknown domain)"
}
var states []happydns.CheckState
if eval != nil {
states = eval.States
}
return &notifPkg.NotificationPayload{
Recipient: notifPkg.Recipient{Email: user.Email},
CheckerID: exec.CheckerID,
Target: exec.Target,
DomainName: domainName,
OldStatus: oldStatus,
NewStatus: newStatus,
States: states,
}
}
// Persist the observed status without claiming a notification was sent (policy suppressed it).
func (d *Dispatcher) advanceState(state *happydns.NotificationState, newStatus happydns.Status) {
state.LastStatus = newStatus
if err := d.stateStore.PutState(state); err != nil {
log.Printf("notification: failed to update state: %v", err)
}
}
func (d *Dispatcher) markNotified(state *happydns.NotificationState, newStatus happydns.Status) {
state.LastStatus = newStatus
state.LastNotifiedAt = d.nowFn()
if err := d.stateStore.PutState(state); err != nil {
log.Printf("notification: failed to update state: %v", err)
}
}
func (d *Dispatcher) SendTestNotification(ch *happydns.NotificationChannel, user *happydns.User) error {
return d.tester.Send(ch, user)
}
func (d *Dispatcher) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error {
return d.ack.AcknowledgeIssue(userId, checkerID, target, acknowledgedBy, annotation)
}
func (d *Dispatcher) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
return d.ack.ClearAcknowledgement(userId, checkerID, target)
}
func (d *Dispatcher) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
return d.ack.GetState(userId, checkerID, target)
}
func (d *Dispatcher) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
return d.ack.ListStatesByUser(userId)
}

View file

@ -0,0 +1,117 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"time"
"git.happydns.org/happyDomain/model"
)
type decisionAction int
const (
actionSkip decisionAction = iota
actionAdvance
actionNotify
)
// Reason is for logging only — callers must not branch on its text.
type decision struct {
Action decisionAction
Reason string
IsRecovery bool
IsEscalation bool
ClearAck bool
}
// Pure predicate. nil pref means "no preference" — suppress notify, still advance state. now is injected for quiet-hour tests.
func decide(state *happydns.NotificationState, pref *happydns.NotificationPreference, newStatus happydns.Status, now time.Time) decision {
oldStatus := state.LastStatus
if oldStatus == newStatus {
return decision{Action: actionSkip, Reason: "no transition"}
}
isRecovery := newStatus < happydns.StatusWarn && oldStatus >= happydns.StatusWarn
isEscalation := newStatus > oldStatus && newStatus >= happydns.StatusWarn
clearAck := isRecovery || isEscalation
d := decision{IsRecovery: isRecovery, IsEscalation: isEscalation, ClearAck: clearAck}
if pref == nil {
d.Action = actionAdvance
d.Reason = "no preference configured"
return d
}
if !pref.Enabled {
d.Action = actionAdvance
d.Reason = "preference disabled"
return d
}
if !isRecovery && newStatus < pref.MinStatus {
d.Action = actionAdvance
d.Reason = "below MinStatus threshold"
return d
}
if isRecovery && !pref.NotifyRecovery {
d.Action = actionAdvance
d.Reason = "recovery suppressed by preference"
return d
}
// Active ack means user already knows; recoveries skip this check.
if state.Acknowledged && !clearAck && !isRecovery {
d.Action = actionAdvance
d.Reason = "acknowledged"
return d
}
if isQuietHour(pref, now) {
d.Action = actionAdvance
d.Reason = "quiet hours"
return d
}
d.Action = actionNotify
d.Reason = "notify"
return d
}
func isQuietHour(pref *happydns.NotificationPreference, now time.Time) bool {
if pref.QuietStart == nil || pref.QuietEnd == nil {
return false
}
loc := time.UTC
if pref.Timezone != "" {
// Validated at write time; on a stale/invalid value we silently fall back to UTC rather than firing during what the user thinks are quiet hours.
if l, err := time.LoadLocation(pref.Timezone); err == nil {
loc = l
}
}
hour := now.In(loc).Hour()
start := *pref.QuietStart
end := *pref.QuietEnd
if start <= end {
return hour >= start && hour < end
}
// Wraps midnight, e.g. 22:00 - 06:00.
return hour >= start || hour < end
}

View file

@ -0,0 +1,226 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
func ptr[T any](v T) *T { return &v }
func TestDecide(t *testing.T) {
noon := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
night := time.Date(2026, 4, 29, 3, 0, 0, 0, time.UTC)
enabled := func() *happydns.NotificationPreference {
return &happydns.NotificationPreference{
Enabled: true,
MinStatus: happydns.StatusWarn,
NotifyRecovery: true,
}
}
tests := []struct {
name string
state happydns.NotificationState
pref *happydns.NotificationPreference
newStatus happydns.Status
now time.Time
wantAction decisionAction
wantClear bool
}{
{
name: "no transition",
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
pref: enabled(),
newStatus: happydns.StatusOK,
now: noon,
wantAction: actionSkip,
},
{
name: "nil preference advances state without notifying",
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
pref: nil,
newStatus: happydns.StatusCrit,
now: noon,
wantAction: actionAdvance,
wantClear: true,
},
{
name: "preference disabled advances",
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
pref: &happydns.NotificationPreference{
Enabled: false,
MinStatus: happydns.StatusWarn,
},
newStatus: happydns.StatusCrit,
now: noon,
wantAction: actionAdvance,
wantClear: true,
},
{
name: "below MinStatus advances",
state: happydns.NotificationState{LastStatus: happydns.StatusUnknown},
pref: enabled(),
newStatus: happydns.StatusOK,
now: noon,
wantAction: actionAdvance,
},
{
name: "recovery suppressed when NotifyRecovery is false",
state: happydns.NotificationState{LastStatus: happydns.StatusCrit},
pref: &happydns.NotificationPreference{
Enabled: true,
MinStatus: happydns.StatusWarn,
NotifyRecovery: false,
},
newStatus: happydns.StatusOK,
now: noon,
wantAction: actionAdvance,
wantClear: true,
},
{
name: "recovery notifies when NotifyRecovery is true",
state: happydns.NotificationState{LastStatus: happydns.StatusCrit},
pref: enabled(),
newStatus: happydns.StatusOK,
now: noon,
wantAction: actionNotify,
wantClear: true,
},
{
name: "escalation notifies and clears ack",
state: happydns.NotificationState{LastStatus: happydns.StatusWarn, Acknowledged: true},
pref: enabled(),
newStatus: happydns.StatusCrit,
now: noon,
wantAction: actionNotify,
wantClear: true,
},
{
name: "acknowledged non-recovery is suppressed",
state: happydns.NotificationState{LastStatus: happydns.StatusCrit, Acknowledged: true},
pref: enabled(),
newStatus: happydns.StatusWarn,
now: noon,
wantAction: actionAdvance,
wantClear: false,
},
{
name: "quiet hours suppress alert",
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
pref: &happydns.NotificationPreference{
Enabled: true,
MinStatus: happydns.StatusWarn,
NotifyRecovery: true,
QuietStart: ptr(22),
QuietEnd: ptr(6),
},
newStatus: happydns.StatusCrit,
now: night,
wantAction: actionAdvance,
wantClear: true,
},
{
name: "outside quiet hours notifies",
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
pref: &happydns.NotificationPreference{
Enabled: true,
MinStatus: happydns.StatusWarn,
NotifyRecovery: true,
QuietStart: ptr(22),
QuietEnd: ptr(6),
},
newStatus: happydns.StatusCrit,
now: noon,
wantAction: actionNotify,
wantClear: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := decide(&tc.state, tc.pref, tc.newStatus, tc.now)
if got.Action != tc.wantAction {
t.Errorf("Action: got %v (%s), want %v", got.Action, got.Reason, tc.wantAction)
}
if got.ClearAck != tc.wantClear {
t.Errorf("ClearAck: got %v, want %v", got.ClearAck, tc.wantClear)
}
})
}
}
func TestIsQuietHour(t *testing.T) {
at := func(h int) time.Time {
return time.Date(2026, 4, 29, h, 30, 0, 0, time.UTC)
}
tests := []struct {
name string
start *int
end *int
hour int
expect bool
}{
{"no window", nil, nil, 3, false},
{"inside non-wrap (9-17)", ptr(9), ptr(17), 12, true},
{"outside non-wrap (9-17)", ptr(9), ptr(17), 18, false},
{"end-exclusive (9-17 at 17)", ptr(9), ptr(17), 17, false},
{"wrap inside before midnight (22-6)", ptr(22), ptr(6), 23, true},
{"wrap inside after midnight (22-6)", ptr(22), ptr(6), 3, true},
{"wrap outside (22-6)", ptr(22), ptr(6), 12, false},
{"wrap end-exclusive (22-6 at 6)", ptr(22), ptr(6), 6, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pref := &happydns.NotificationPreference{QuietStart: tc.start, QuietEnd: tc.end}
if got := isQuietHour(pref, at(tc.hour)); got != tc.expect {
t.Errorf("got %v, want %v", got, tc.expect)
}
})
}
}
func TestIsQuietHourTimezone(t *testing.T) {
// 02:30 UTC == 12:30 Asia/Tokyo (UTC+9), so a 9-17 quiet window in Tokyo should fire while UTC says off-hours.
now := time.Date(2026, 4, 29, 2, 30, 0, 0, time.UTC)
pref := &happydns.NotificationPreference{
QuietStart: ptr(9),
QuietEnd: ptr(17),
Timezone: "Asia/Tokyo",
}
if !isQuietHour(pref, now) {
t.Fatalf("expected quiet hour in Asia/Tokyo at local 11:30, got false")
}
pref.Timezone = ""
if isQuietHour(pref, now) {
t.Fatalf("expected non-quiet in UTC at 02:30, got true")
}
// Invalid TZ falls back to UTC.
pref.Timezone = "Not/AReal_Zone"
if isQuietHour(pref, now) {
t.Fatalf("expected fallback to UTC for invalid timezone, got quiet hour")
}
}

View file

@ -0,0 +1,174 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"context"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
notifPkg "git.happydns.org/happyDomain/internal/notification"
"git.happydns.org/happyDomain/model"
)
const (
dispatchWorkers = 4
// On overflow, an audit record is written so back-pressure losses are visible in history.
dispatchQueueSize = 256
// Caps a single Send so a wedged endpoint cannot starve workers.
sendTimeout = 15 * time.Second
// Bounds the persisted error to keep the audit log small.
maxRecordErrorLen = 512
)
func truncateError(s string) string {
const marker = "…[truncated]"
if len(s) <= maxRecordErrorLen {
return s
}
return s[:maxRecordErrorLen-len(marker)] + marker
}
type dispatchJob struct {
channel *happydns.NotificationChannel
payload *notifPkg.NotificationPayload
user *happydns.User
}
// Async send fan-out; no policy — caller decides whether to enqueue.
type Pool struct {
registry *notifPkg.Registry
recordStore NotificationRecordStorage
jobs chan dispatchJob
wg sync.WaitGroup
stopped atomic.Bool
stopOnce sync.Once
// Overridable for tests.
nowFn func() time.Time
}
func NewPool(registry *notifPkg.Registry, recordStore NotificationRecordStorage) *Pool {
return &Pool{
registry: registry,
recordStore: recordStore,
jobs: make(chan dispatchJob, dispatchQueueSize),
nowFn: time.Now,
}
}
func (p *Pool) Start() {
for range dispatchWorkers {
p.wg.Add(1)
go p.worker()
}
}
// Idempotent. Post-Stop, Enqueue is a no-op so a racing caller doesn't panic on send-to-closed-channel.
func (p *Pool) Stop() {
p.stopOnce.Do(func() {
p.stopped.Store(true)
close(p.jobs)
})
p.wg.Wait()
}
func (p *Pool) worker() {
defer p.wg.Done()
for job := range p.jobs {
p.sendAndRecord(job.channel, job.payload, job.user)
}
}
// On saturation, persists an audit record so the missed alert surfaces in history.
func (p *Pool) Enqueue(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) bool {
if p.stopped.Load() {
return false
}
job := dispatchJob{channel: ch, payload: payload, user: user}
select {
case p.jobs <- job:
return true
default:
// Saturated: record the miss rather than silently drop.
log.Printf("notification: dispatch queue full, recording back-pressure failure for channel %q (%q)", ch.Id, ch.Type)
p.recordSaturation(ch, payload, user)
return false
}
}
func (p *Pool) recordSaturation(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) {
rec := newRecord(ch, payload, user, p.nowFn())
rec.Success = false
rec.Error = "dispatch queue saturated"
if err := p.recordStore.CreateRecord(rec); err != nil {
log.Printf("notification: failed to log saturation record: %v", err)
}
}
func (p *Pool) sendAndRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) {
rec := newRecord(ch, payload, user, p.nowFn())
if err := p.runSend(ch, payload); err != nil {
log.Printf("notification: failed to send via %q channel %q: %v", ch.Type, ch.Id, err)
rec.Success = false
rec.Error = truncateError(err.Error())
} else {
rec.Success = true
}
if err := p.recordStore.CreateRecord(rec); err != nil {
log.Printf("notification: failed to log record: %v", err)
}
}
func (p *Pool) runSend(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload) error {
sender, ok := p.registry.Get(ch.Type)
if !ok {
return fmt.Errorf("no sender for channel type %q", ch.Type)
}
cfg, err := sender.DecodeConfig(ch.Config)
if err != nil {
return fmt.Errorf("invalid config for channel %s: %w", ch.Id, err)
}
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
defer cancel()
return sender.Send(ctx, cfg, payload)
}
// Caller fills Success/Error after the send attempt.
func newRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User, sentAt time.Time) *happydns.NotificationRecord {
return &happydns.NotificationRecord{
UserId: user.Id,
ChannelType: ch.Type,
ChannelId: ch.Id,
CheckerID: payload.CheckerID,
Target: payload.Target,
OldStatus: payload.OldStatus,
NewStatus: payload.NewStatus,
SentAt: sentAt,
}
}

View file

@ -0,0 +1,89 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"log"
"git.happydns.org/happyDomain/model"
)
// Read-only, safe to share between goroutines.
type Resolver struct {
channelStore NotificationChannelStorage
prefStore NotificationPreferenceStorage
}
func NewResolver(channelStore NotificationChannelStorage, prefStore NotificationPreferenceStorage) *Resolver {
return &Resolver{channelStore: channelStore, prefStore: prefStore}
}
// Specificity service > domain > global; falls back to DefaultNotificationPreference so opt-in defaults flow through.
func (r *Resolver) ResolvePreference(user *happydns.User, target happydns.CheckTarget) *happydns.NotificationPreference {
prefs, err := r.prefStore.ListPreferencesByUser(user.Id)
if err != nil {
log.Printf("notification: failed to load preferences for user %q: %v", user.Id, err)
return happydns.DefaultNotificationPreference()
}
var best *happydns.NotificationPreference
bestSpecificity := -1
for _, p := range prefs {
s := p.MatchesTarget(target)
if s > bestSpecificity {
best = p
bestSpecificity = s
}
}
if best == nil {
return happydns.DefaultNotificationPreference()
}
return best
}
func (r *Resolver) ResolveChannels(user *happydns.User, pref *happydns.NotificationPreference) []*happydns.NotificationChannel {
allChannels, err := r.channelStore.ListChannelsByUser(user.Id)
if err != nil {
log.Printf("notification: failed to load channels for user %q: %v", user.Id, err)
return nil
}
var allowed map[string]bool
if len(pref.ChannelIds) > 0 {
allowed = make(map[string]bool, len(pref.ChannelIds))
for _, id := range pref.ChannelIds {
allowed[id.String()] = true
}
}
var result []*happydns.NotificationChannel
for _, ch := range allChannels {
if !ch.Enabled {
continue
}
if allowed != nil && !allowed[ch.Id.String()] {
continue
}
result = append(result, ch)
}
return result
}

View file

@ -0,0 +1,98 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"errors"
"testing"
"git.happydns.org/happyDomain/model"
)
type fakePrefStore struct {
prefs []*happydns.NotificationPreference
err error
}
func (f *fakePrefStore) ListPreferencesByUser(_ happydns.Identifier) ([]*happydns.NotificationPreference, error) {
return f.prefs, f.err
}
func (f *fakePrefStore) GetPreference(_ happydns.Identifier) (*happydns.NotificationPreference, error) {
return nil, nil
}
func (f *fakePrefStore) CreatePreference(_ *happydns.NotificationPreference) error { return nil }
func (f *fakePrefStore) UpdatePreference(_ *happydns.NotificationPreference) error { return nil }
func (f *fakePrefStore) DeletePreference(_ happydns.Identifier) error { return nil }
func TestResolvePreferenceFallsBackToDefault(t *testing.T) {
user := &happydns.User{Id: happydns.Identifier{1}}
target := happydns.CheckTarget{UserId: user.Id.String(), DomainId: "dom-1"}
t.Run("no preferences returns opt-in default", func(t *testing.T) {
r := NewResolver(nil, &fakePrefStore{})
got := r.ResolvePreference(user, target)
if got == nil {
t.Fatal("expected default preference, got nil")
}
if !got.Enabled || got.MinStatus != happydns.StatusWarn {
t.Errorf("default not opt-in: %+v", got)
}
})
t.Run("store error returns default", func(t *testing.T) {
r := NewResolver(nil, &fakePrefStore{err: errors.New("boom")})
if got := r.ResolvePreference(user, target); got == nil || !got.Enabled {
t.Errorf("expected enabled default on store error, got %+v", got)
}
})
t.Run("matching preference wins over default", func(t *testing.T) {
domId, _ := happydns.NewIdentifierFromString("dom-1")
user := &happydns.User{Id: happydns.Identifier{1}}
userPref := &happydns.NotificationPreference{
DomainId: &domId,
Enabled: true,
MinStatus: happydns.StatusCrit,
}
r := NewResolver(nil, &fakePrefStore{prefs: []*happydns.NotificationPreference{userPref}})
got := r.ResolvePreference(user, happydns.CheckTarget{DomainId: domId.String()})
if got != userPref {
t.Errorf("expected user preference, got %+v", got)
}
})
t.Run("non-matching scoped preference falls back to default", func(t *testing.T) {
otherDom, _ := happydns.NewIdentifierFromString("dom-other")
userPref := &happydns.NotificationPreference{
DomainId: &otherDom,
Enabled: true,
}
r := NewResolver(nil, &fakePrefStore{prefs: []*happydns.NotificationPreference{userPref}})
got := r.ResolvePreference(user, happydns.CheckTarget{DomainId: "dom-1"})
if got == nil || got == userPref {
t.Errorf("expected default fallback, got %+v", got)
}
if !got.Enabled || got.MinStatus != happydns.StatusWarn {
t.Errorf("default not opt-in: %+v", got)
}
})
}

View file

@ -0,0 +1,83 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"fmt"
"sync"
"git.happydns.org/happyDomain/model"
)
// Per-key serialization for state read-modify-write; without it, ack and dispatcher races wipe acks or fire duplicates. In-process only — single instance.
type StateLocker struct {
mu sync.Mutex
locks map[string]*stateLockEntry
}
type stateLockEntry struct {
mu sync.Mutex
refCount int
}
func NewStateLocker() *StateLocker {
return &StateLocker{locks: make(map[string]*stateLockEntry)}
}
// Always defer the returned unlock — leaking it pins the map entry.
func (l *StateLocker) Lock(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) func() {
key := stateLockKey(checkerID, target, userId)
l.mu.Lock()
entry, ok := l.locks[key]
if !ok {
entry = &stateLockEntry{}
l.locks[key] = entry
}
entry.refCount++
l.mu.Unlock()
entry.mu.Lock()
return func() {
entry.mu.Unlock()
l.mu.Lock()
entry.refCount--
if entry.refCount == 0 {
delete(l.locks, key)
}
l.mu.Unlock()
}
}
// Must match the storage tuple exactly; mismatch silently re-introduces the race.
func stateLockKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
return fmt.Sprintf(
"%s|%s|%s/%s/%s",
userId.String(),
checkerID,
target.UserId,
target.DomainId,
target.ServiceId,
)
}

View file

@ -0,0 +1,65 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"time"
"git.happydns.org/happyDomain/model"
)
type NotificationChannelStorage interface {
ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error)
GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error)
CreateChannel(ch *happydns.NotificationChannel) error
UpdateChannel(ch *happydns.NotificationChannel) error
DeleteChannel(channelId happydns.Identifier) error
}
type NotificationPreferenceStorage interface {
ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error)
GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error)
CreatePreference(pref *happydns.NotificationPreference) error
UpdatePreference(pref *happydns.NotificationPreference) error
DeletePreference(prefId happydns.Identifier) error
}
type NotificationStateStorage interface {
GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error)
PutState(state *happydns.NotificationState) error
DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error
ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error)
}
type NotificationRecordStorage interface {
CreateRecord(rec *happydns.NotificationRecord) error
ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error)
DeleteRecordsOlderThan(before time.Time) error
}
type UserGetter interface {
GetUser(id happydns.Identifier) (*happydns.User, error)
}
type DomainGetter interface {
GetDomain(id happydns.Identifier) (*happydns.Domain, error)
}

View file

@ -0,0 +1,52 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"context"
notifPkg "git.happydns.org/happyDomain/internal/notification"
"git.happydns.org/happyDomain/model"
)
// Synchronous and bypasses preferences/state/quiet-hours — user explicitly verifying one channel.
type Tester struct {
registry *notifPkg.Registry
}
func NewTester(registry *notifPkg.Registry) *Tester {
return &Tester{registry: registry}
}
func (t *Tester) Send(ch *happydns.NotificationChannel, user *happydns.User) error {
sender, ok := t.registry.Get(ch.Type)
if !ok {
return notifPkg.ErrUnknownChannelType
}
cfg, err := sender.DecodeConfig(ch.Config)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
defer cancel()
return sender.SendTest(ctx, cfg, user)
}

View file

@ -27,21 +27,24 @@ import (
)
var (
ErrAuthUserNotFound = errors.New("auth user not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrAuthUserNotFound = errors.New("auth user not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrNotificationChannelNotFound = errors.New("notification channel not found")
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
ErrNotificationStateNotFound = errors.New("notification state not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

123
model/notification.go Normal file
View file

@ -0,0 +1,123 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns
import (
"encoding/json"
"time"
)
// Values are owned by sender implementations; the model does not enumerate them.
type NotificationChannelType string
// Config is opaque to the model: decoded by the sender registered for Type.
type NotificationChannel struct {
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
Type NotificationChannelType `json:"type" binding:"required"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config" swaggertype:"object"`
}
// Scope resolution: ServiceId set > DomainId set > global (both nil).
type NotificationPreference struct {
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"`
ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"`
// Empty means all enabled channels.
ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"`
MinStatus Status `json:"minStatus"`
NotifyRecovery bool `json:"notifyRecovery"`
// Hours 0-23, interpreted in Timezone (IANA name; empty means UTC).
QuietStart *int `json:"quietStart,omitempty"`
QuietEnd *int `json:"quietEnd,omitempty"`
Timezone string `json:"timezone,omitempty"`
Enabled bool `json:"enabled"`
}
// Used for deduplication: only state transitions trigger notifications.
type NotificationState struct {
CheckerID string `json:"checkerId"`
Target CheckTarget `json:"target"`
UserId Identifier `json:"userId" swaggertype:"string"`
LastStatus Status `json:"lastStatus"`
LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"`
Acknowledged bool `json:"acknowledged"`
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"`
// User email or "api".
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
Annotation string `json:"annotation,omitempty"`
}
type NotificationRecord struct {
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
UserId Identifier `json:"userId" swaggertype:"string"`
ChannelType NotificationChannelType `json:"channelType"`
ChannelId Identifier `json:"channelId" swaggertype:"string"`
CheckerID string `json:"checkerId"`
Target CheckTarget `json:"target"`
OldStatus Status `json:"oldStatus"`
NewStatus Status `json:"newStatus"`
SentAt time.Time `json:"sentAt" format:"date-time"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type AcknowledgeRequest struct {
Annotation string `json:"annotation,omitempty"`
}
// Called both on explicit user clear and when a transition invalidates the ack.
func (s *NotificationState) ClearAcknowledgement() {
s.Acknowledged = false
s.AcknowledgedAt = nil
s.AcknowledgedBy = ""
s.Annotation = ""
}
// Implicit fallback when no preference is configured: opt-in at Warn+ on all enabled channels. Returned with zero Id/UserId; not persisted.
func DefaultNotificationPreference() *NotificationPreference {
return &NotificationPreference{
MinStatus: StatusWarn,
NotifyRecovery: false,
Enabled: true,
}
}
// Returns 2 service / 1 domain / 0 global / -1 no-match.
func (p *NotificationPreference) MatchesTarget(target CheckTarget) int {
if p.ServiceId != nil {
if p.ServiceId.String() == target.ServiceId {
return 2
}
return -1
}
if p.DomainId != nil {
if p.DomainId.String() == target.DomainId {
return 1
}
return -1
}
return 0
}

View file

@ -53,6 +53,10 @@ var entityMap = map[string]string{
"DomainStorage": "domain",
"DomainLogStorage": "domain_log",
"InsightStorage": "insight",
"NotificationChannelStorage": "notification_channel",
"NotificationPreferenceStorage": "notification_preference",
"NotificationStateStorage": "notification_state",
"NotificationRecordStorage": "notification_record",
"ProviderStorage": "provider",
"SessionStorage": "session",
"UserStorage": "user",

View file

@ -0,0 +1,147 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import {
getNotificationsChannelTypes,
getNotificationsChannels,
postNotificationsChannels,
getNotificationsChannelsByChannelId,
putNotificationsChannelsByChannelId,
deleteNotificationsChannelsByChannelId,
postNotificationsChannelsByChannelIdTest,
getNotificationsPreferences,
postNotificationsPreferences,
getNotificationsPreferencesByPrefId,
putNotificationsPreferencesByPrefId,
deleteNotificationsPreferencesByPrefId,
getNotificationsHistory,
} from "$lib/api-base/sdk.gen";
import type {
HappydnsNotificationChannel,
HappydnsNotificationChannelWritable,
HappydnsNotificationPreference,
HappydnsNotificationPreferenceWritable,
HappydnsNotificationRecord,
} from "$lib/api-base/types.gen";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
// Extend Writable types with Config — OpenAPI models it as free-form so the generated types omit it.
export type NotificationChannelInput = HappydnsNotificationChannelWritable & {
config?: Record<string, unknown>;
};
export type NotificationChannel = HappydnsNotificationChannel & {
config?: Record<string, unknown>;
};
export type NotificationPreferenceInput = HappydnsNotificationPreferenceWritable;
export type NotificationPreference = HappydnsNotificationPreference;
export type NotificationRecord = HappydnsNotificationRecord;
export async function listChannelTypes(): Promise<string[]> {
return unwrapSdkResponse(await getNotificationsChannelTypes()) as string[];
}
export async function listChannels(): Promise<NotificationChannel[]> {
return unwrapSdkResponse(await getNotificationsChannels()) as NotificationChannel[];
}
export async function getChannel(id: string): Promise<NotificationChannel> {
return unwrapSdkResponse(
await getNotificationsChannelsByChannelId({ path: { channelId: id } }),
) as NotificationChannel;
}
export async function createChannel(
channel: NotificationChannelInput,
): Promise<NotificationChannel> {
return unwrapSdkResponse(
await postNotificationsChannels({
body: channel as HappydnsNotificationChannelWritable,
}),
) as NotificationChannel;
}
export async function updateChannel(
id: string,
channel: NotificationChannelInput,
): Promise<NotificationChannel> {
return unwrapSdkResponse(
await putNotificationsChannelsByChannelId({
path: { channelId: id },
body: channel as HappydnsNotificationChannelWritable,
}),
) as NotificationChannel;
}
export async function deleteChannel(id: string): Promise<boolean> {
return unwrapEmptyResponse(
await deleteNotificationsChannelsByChannelId({ path: { channelId: id } }),
);
}
export async function testChannel(id: string): Promise<boolean> {
return unwrapEmptyResponse(
await postNotificationsChannelsByChannelIdTest({ path: { channelId: id } }),
);
}
export async function listPreferences(): Promise<NotificationPreference[]> {
return unwrapSdkResponse(await getNotificationsPreferences()) as NotificationPreference[];
}
export async function getPreference(id: string): Promise<NotificationPreference> {
return unwrapSdkResponse(
await getNotificationsPreferencesByPrefId({ path: { prefId: id } }),
) as NotificationPreference;
}
export async function createPreference(
pref: NotificationPreferenceInput,
): Promise<NotificationPreference> {
return unwrapSdkResponse(
await postNotificationsPreferences({ body: pref }),
) as NotificationPreference;
}
export async function updatePreference(
id: string,
pref: NotificationPreferenceInput,
): Promise<NotificationPreference> {
return unwrapSdkResponse(
await putNotificationsPreferencesByPrefId({
path: { prefId: id },
body: pref,
}),
) as NotificationPreference;
}
export async function deletePreference(id: string): Promise<boolean> {
return unwrapEmptyResponse(
await deleteNotificationsPreferencesByPrefId({ path: { prefId: id } }),
);
}
export async function listHistory(limit?: number): Promise<NotificationRecord[]> {
return unwrapSdkResponse(
await getNotificationsHistory({ query: limit ? { limit } : undefined }),
) as NotificationRecord[];
}

View file

@ -54,6 +54,7 @@
"previous": "Previous",
"rename": "Rename",
"resolver": "Resolver",
"save": "Save",
"run": "Run the request!",
"survey": "A remark? A comment to share? Don't hesitate to write to us!",
"update": "Update",
@ -514,6 +515,117 @@
"title": "Preferences",
"description": "Customize your preferences"
},
"notifications": {
"title": "Notifications",
"subtitle": "Configure how and when happyDomain notifies you about issues",
"description": "Manage notification channels, preferences and history",
"manage": "Manage notifications",
"channels": {
"title": "Channels",
"description": "Destinations where notifications are sent",
"add": "Add channel",
"edit": "Edit channel",
"delete": "Delete channel",
"test": "Send test",
"type": "Type",
"typeImmutable": "Channel type cannot be changed after creation",
"name": "Name",
"enabled": "Enabled",
"disabled": "Disabled",
"unnamed": "(unnamed)",
"empty": "No channels configured yet. Add one to start receiving notifications.",
"rawJson": "Raw JSON configuration",
"rawJsonHelp": "This channel type is not known to the UI. Edit the JSON configuration directly.",
"created": "Channel created",
"updated": "Channel updated",
"deleted": "Channel deleted",
"testSent": "Test notification sent",
"confirmDelete": "Delete this channel? Existing preferences referencing it will fall back to enabled channels.",
"loadError": "Failed to load channels",
"typesError": "Failed to load channel types",
"saveError": "Failed to save channel",
"deleteError": "Failed to delete channel",
"testError": "Failed to send test notification",
"fields": {
"emailAddress": "Email address",
"emailAddressHelp": "Leave empty to use your account email.",
"webhookUrl": "Webhook URL",
"webhookHeaders": "Custom headers",
"webhookHeadersHelp": "Extra HTTP headers sent with each webhook request.",
"webhookSecret": "Signing secret",
"webhookSecretHelp": "If set, requests are signed with HMAC-SHA256.",
"unifiedPushEndpoint": "UnifiedPush endpoint",
"unifiedPushEndpointHelp": "Endpoint URL provided by your UnifiedPush distributor.",
"headerName": "Header",
"headerValue": "Value",
"addHeader": "Add header"
}
},
"preferences": {
"title": "Preferences",
"description": "Filter what notifications you receive and where they go",
"add": "Add preference",
"edit": "Edit preference",
"delete": "Delete preference",
"empty": "No preferences yet. Add one to choose what you get notified about.",
"scope": {
"label": "Scope",
"global": "All domains",
"domain": "Specific domain",
"service": "Specific service",
"serviceLabel": "{{domain}} / service {{service}}"
},
"domain": "Domain",
"selectDomain": "Select a domain…",
"serviceId": "Service ID",
"serviceIdHelp": "Identifier of the service inside the selected domain.",
"channels": "Channels",
"channelsHelp": "Leave all unchecked to use all enabled channels.",
"noChannels": "Add at least one channel to assign it to a preference.",
"allChannels": "All enabled channels",
"minStatus": "Minimum severity",
"minStatusHelp": "Only statuses at or above this severity trigger a notification.",
"notifyRecovery": "Notify on recovery (back to OK)",
"recovery": "Recovery",
"quietHours": "Quiet hours",
"quietHoursHelp": "Hours given in UTC. Notifications are suppressed during the window.",
"quietStart": "Start (UTC hour)",
"quietEnd": "End (UTC hour)",
"enabled": "Enabled",
"disabled": "Disabled",
"created": "Preference created",
"updated": "Preference updated",
"deleted": "Preference deleted",
"confirmDelete": "Delete this preference?",
"loadError": "Failed to load preferences",
"saveError": "Failed to save preference",
"deleteError": "Failed to delete preference",
"defaults": {
"title": "Default settings",
"description": "Used when no rule matches the target.",
"minStatus": "Notifies on Warning and above",
"channels": "Sent to every enabled channel",
"noRecovery": "Recovery alerts are not sent",
"override": "Add a rule below to override these defaults."
}
},
"history": {
"title": "History",
"description": "Recent notifications sent on your behalf",
"empty": "No notifications have been sent yet.",
"refresh": "Refresh",
"loadMore": "Load more",
"sentAt": "Sent at",
"channel": "Channel",
"checker": "Checker",
"target": "Target",
"transition": "Status change",
"result": "Result",
"success": "Sent",
"failure": "Failed",
"loadError": "Failed to load history"
}
},
"security": {
"title": "Security & Access",
"description": "Manage your authentication and security settings",

View file

@ -0,0 +1,41 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { writable, type Writable } from "svelte/store";
import { listChannelTypes } from "$lib/api/notifications";
export const notificationChannelTypes: Writable<string[] | undefined> = writable(undefined);
let pending: Promise<string[]> | undefined;
export async function refreshNotificationChannelTypes(): Promise<string[]> {
if (pending) return pending;
pending = (async () => {
try {
const types = await listChannelTypes();
notificationChannelTypes.set(types);
return types;
} finally {
pending = undefined;
}
})();
return pending;
}

View file

@ -51,6 +51,7 @@ interface Params {
intervalMin?: string;
intervalMax?: string;
intervalDefault?: string;
service?: string;
// add more parameters that are used here
}

View file

@ -59,6 +59,17 @@
<UserSettingsForm bind:settings={$userSession.settings} />
<h2 id="notifications" class="display-7 fw-bold mt-5">
<i class="bi bi-bell"></i>
{$t("settings.notifications.title")}
</h2>
<p class="lead">
{$t("settings.notifications.description")}
</p>
<a class="btn btn-outline-primary" href="/me/notifications">
{$t("settings.notifications.manage")}
</a>
{#if $userSession.email !== "_no_auth"}
<h2 id="security" class="display-7 fw-bold mt-5">
<i class="bi bi-shield"></i>

View file

@ -0,0 +1,62 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Container, TabContent, TabPane } from "@sveltestrap/sveltestrap";
import PageTitle from "$lib/components/PageTitle.svelte";
import { t } from "$lib/translations";
import ChannelsManager from "./ChannelsManager.svelte";
import HistoryList from "./HistoryList.svelte";
import PreferencesManager from "./PreferencesManager.svelte";
</script>
<svelte:head>
<title>{$t("settings.notifications.title")} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-4 pb-5">
<PageTitle
title={$t("settings.notifications.title")}
subtitle={$t("settings.notifications.subtitle")}
/>
<TabContent>
<TabPane tabId="channels" tab={$t("settings.notifications.channels.title")} active>
<div class="pt-3">
<ChannelsManager />
</div>
</TabPane>
<TabPane tabId="preferences" tab={$t("settings.notifications.preferences.title")}>
<div class="pt-3">
<PreferencesManager />
</div>
</TabPane>
<TabPane tabId="history" tab={$t("settings.notifications.history.title")}>
<div class="pt-3">
<HistoryList />
</div>
</TabPane>
</TabContent>
</Container>

View file

@ -0,0 +1,375 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { untrack } from "svelte";
import {
Button,
Input,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Spinner,
} from "@sveltestrap/sveltestrap";
import { createChannel, updateChannel, type NotificationChannel } from "$lib/api/notifications";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
import {
getChannelConfigSchema,
emptyConfigForSchema,
type ChannelConfigField,
} from "./channelConfigs";
interface Props {
open: boolean;
channelTypes: string[];
channel: NotificationChannel | null;
onSave: () => void;
onClose: () => void;
}
let { open, channelTypes, channel, onSave, onClose }: Props = $props();
let type: string = $state("");
let name: string = $state("");
let enabled: boolean = $state(true);
let config: Record<string, unknown> = $state({});
let rawJson: string = $state("{}");
let rawJsonError: string | null = $state(null);
let saving: boolean = $state(false);
let revealedSecrets: Record<string, boolean> = $state({});
// Reset only on open transition; other props read via untrack so type loading doesn't retrigger.
$effect(() => {
if (!open) return;
untrack(() => {
const c = channel;
const types = channelTypes;
if (c) {
type = c.type ?? "";
name = c.name ?? "";
enabled = c.enabled ?? true;
config = (c.config ?? {}) as Record<string, unknown>;
} else {
type = types[0] ?? "";
name = "";
enabled = true;
const schema = getChannelConfigSchema(type);
config = schema ? emptyConfigForSchema(schema) : {};
}
rawJson = JSON.stringify(config, null, 2);
rawJsonError = null;
revealedSecrets = {};
});
});
function onTypeChange() {
// Reset to schema defaults so we never send fields that don't apply.
const schema = getChannelConfigSchema(type);
config = schema ? emptyConfigForSchema(schema) : {};
rawJson = JSON.stringify(config, null, 2);
rawJsonError = null;
}
let schema = $derived(getChannelConfigSchema(type));
function setHeaderKey(field: ChannelConfigField, oldKey: string, newKey: string) {
const cur = (config[field.key] as Record<string, string>) ?? {};
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(cur)) {
next[k === oldKey ? newKey : k] = v;
}
config = { ...config, [field.key]: next };
}
function setHeaderValue(field: ChannelConfigField, key: string, value: string) {
const cur = (config[field.key] as Record<string, string>) ?? {};
config = { ...config, [field.key]: { ...cur, [key]: value } };
}
function addHeader(field: ChannelConfigField) {
const cur = (config[field.key] as Record<string, string>) ?? {};
if ("" in cur) return;
config = { ...config, [field.key]: { ...cur, "": "" } };
}
function removeHeader(field: ChannelConfigField, key: string) {
const cur = { ...((config[field.key] as Record<string, string>) ?? {}) };
delete cur[key];
config = { ...config, [field.key]: cur };
}
async function save() {
let payloadConfig: Record<string, unknown>;
if (schema) {
payloadConfig = config;
} else {
try {
payloadConfig = JSON.parse(rawJson);
rawJsonError = null;
} catch (e) {
rawJsonError = (e as Error).message;
return;
}
}
saving = true;
try {
if (channel?.id) {
await updateChannel(channel.id, {
type,
name,
enabled,
config: payloadConfig,
});
} else {
await createChannel({
type,
name,
enabled,
config: payloadConfig,
});
}
toasts.addToast({
title: $t(
channel?.id
? "settings.notifications.channels.updated"
: "settings.notifications.channels.created",
),
timeout: 4000,
type: "success",
});
onSave();
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.channels.saveError"),
message: String(e),
timeout: 8000,
});
} finally {
saving = false;
}
}
</script>
<Modal isOpen={open} toggle={onClose} size="lg">
<ModalHeader toggle={onClose}>
{channel?.id
? $t("settings.notifications.channels.edit")
: $t("settings.notifications.channels.add")}
</ModalHeader>
<ModalBody>
<form id="channel-editor-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
<div class="mb-3">
<label for="channel-type" class="form-label">
{$t("settings.notifications.channels.type")}
</label>
<Input
id="channel-type"
type="select"
bind:value={type}
on:change={onTypeChange}
disabled={!!channel?.id}
>
{#each channelTypes as t (t)}
<option value={t}>{t}</option>
{/each}
</Input>
{#if channel?.id}
<small class="form-text text-muted">
{$t("settings.notifications.channels.typeImmutable")}
</small>
{/if}
</div>
<div class="mb-3">
<label for="channel-name" class="form-label">
{$t("settings.notifications.channels.name")}
</label>
<Input id="channel-name" type="text" bind:value={name} />
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="channel-enabled"
bind:checked={enabled}
/>
<label class="form-check-label" for="channel-enabled">
{$t("settings.notifications.channels.enabled")}
</label>
</div>
<hr />
{#if schema}
{#each schema.fields as field (field.key)}
<div class="mb-3">
<label for={`field-${field.key}`} class="form-label">
{$t(field.i18nLabel)}
{#if field.required}<span class="text-danger">*</span>{/if}
</label>
{#if field.kind === "text" || field.kind === "url"}
<Input
id={`field-${field.key}`}
type={field.kind === "url" ? "url" : "text"}
value={(config[field.key] as string) ?? ""}
on:input={(e) => {
config = {
...config,
[field.key]: (e.target as HTMLInputElement).value,
};
}}
required={field.required}
/>
{:else if field.kind === "secret"}
<div class="input-group">
<Input
id={`field-${field.key}`}
type={revealedSecrets[field.key] ? "text" : "password"}
value={(config[field.key] as string) ?? ""}
on:input={(e) => {
config = {
...config,
[field.key]: (e.target as HTMLInputElement).value,
};
}}
/>
<Button
type="button"
color="secondary"
outline
on:click={() =>
(revealedSecrets = {
...revealedSecrets,
[field.key]: !revealedSecrets[field.key],
})}
>
<i
class={`bi bi-eye${revealedSecrets[field.key] ? "-slash" : ""}`}
></i>
</Button>
</div>
{:else if field.kind === "headers"}
{@const headers = (config[field.key] as Record<string, string>) ?? {}}
{#each Object.entries(headers) as [k, v] (k)}
<div class="d-flex gap-2 mb-2">
<Input
type="text"
placeholder={$t(
"settings.notifications.channels.fields.headerName",
)}
value={k}
on:change={(e) =>
setHeaderKey(
field,
k,
(e.target as HTMLInputElement).value,
)}
/>
<Input
type="text"
placeholder={$t(
"settings.notifications.channels.fields.headerValue",
)}
value={v}
on:input={(e) =>
setHeaderValue(
field,
k,
(e.target as HTMLInputElement).value,
)}
/>
<Button
type="button"
color="danger"
outline
on:click={() => removeHeader(field, k)}
>
<i class="bi bi-trash"></i>
</Button>
</div>
{/each}
<Button
type="button"
size="sm"
color="secondary"
outline
on:click={() => addHeader(field)}
>
<i class="bi bi-plus-lg"></i>
{$t("settings.notifications.channels.fields.addHeader")}
</Button>
{/if}
{#if field.i18nHelp}
<small class="form-text text-muted d-block">
{$t(field.i18nHelp)}
</small>
{/if}
</div>
{/each}
{:else if type}
<div class="mb-2">
<label for="raw-json" class="form-label">
{$t("settings.notifications.channels.rawJson")}
</label>
<textarea
id="raw-json"
class="form-control font-monospace"
rows="8"
bind:value={rawJson}
></textarea>
{#if rawJsonError}
<div class="text-danger small">{rawJsonError}</div>
{/if}
<small class="form-text text-muted">
{$t("settings.notifications.channels.rawJsonHelp")}
</small>
</div>
{/if}
</form>
</ModalBody>
<ModalFooter>
<Button type="button" color="secondary" outline on:click={onClose} disabled={saving}>
{$t("common.cancel")}
</Button>
<Button
type="submit"
form="channel-editor-form"
color="primary"
disabled={saving || !type}
>
{#if saving}<Spinner size="sm" class="me-2" />{/if}
{$t("common.save")}
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,223 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import { Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
import {
deleteChannel,
listChannels,
testChannel,
type NotificationChannel,
} from "$lib/api/notifications";
import {
notificationChannelTypes,
refreshNotificationChannelTypes,
} from "$lib/stores/notificationTypes";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
import ChannelEditor from "./ChannelEditor.svelte";
let channels: NotificationChannel[] = $state([]);
let loading: boolean = $state(true);
let editorOpen: boolean = $state(false);
let editing: NotificationChannel | null = $state(null);
let busy: Record<string, "test" | "delete"> = $state({});
async function refresh() {
loading = true;
try {
channels = await listChannels();
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.channels.loadError"),
message: String(e),
timeout: 8000,
});
} finally {
loading = false;
}
}
onMount(() => {
refresh();
refreshNotificationChannelTypes().catch((e) =>
toasts.addErrorToast({
title: $t("settings.notifications.channels.typesError"),
message: String(e),
timeout: 8000,
}),
);
});
function openCreate() {
editing = null;
editorOpen = true;
}
function openEdit(channel: NotificationChannel) {
editing = channel;
editorOpen = true;
}
async function onTest(channel: NotificationChannel) {
if (!channel.id) return;
busy = { ...busy, [channel.id]: "test" };
try {
await testChannel(channel.id);
toasts.addToast({
title: $t("settings.notifications.channels.testSent"),
timeout: 5000,
type: "success",
});
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.channels.testError"),
message: String(e),
timeout: 8000,
});
} finally {
const next = { ...busy };
delete next[channel.id];
busy = next;
}
}
async function onDelete(channel: NotificationChannel) {
if (!channel.id) return;
if (!window.confirm($t("settings.notifications.channels.confirmDelete"))) return;
busy = { ...busy, [channel.id]: "delete" };
try {
await deleteChannel(channel.id);
channels = channels.filter((c) => c.id !== channel.id);
toasts.addToast({
title: $t("settings.notifications.channels.deleted"),
timeout: 4000,
type: "success",
});
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.channels.deleteError"),
message: String(e),
timeout: 8000,
});
} finally {
const next = { ...busy };
delete next[channel.id];
busy = next;
}
}
function onSaved() {
editorOpen = false;
refresh();
}
</script>
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="mb-0 text-muted">
{$t("settings.notifications.channels.description")}
</p>
<Button color="primary" size="sm" on:click={openCreate} disabled={!$notificationChannelTypes}>
<i class="bi bi-plus-lg"></i>
{$t("settings.notifications.channels.add")}
</Button>
</div>
{#if loading}
<div class="d-flex justify-content-center py-3">
<Spinner color="primary" />
</div>
{:else if channels.length === 0}
<div class="alert alert-secondary">
{$t("settings.notifications.channels.empty")}
</div>
{:else}
<ul class="list-group">
{#each channels as channel (channel.id)}
<li class="list-group-item d-flex flex-column flex-md-row gap-2 align-items-md-center">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2">
<strong>{channel.name || $t("settings.notifications.channels.unnamed")}</strong>
<Badge color="info">{channel.type}</Badge>
{#if !channel.enabled}
<Badge color="secondary">
{$t("settings.notifications.channels.disabled")}
</Badge>
{/if}
</div>
</div>
<div class="d-flex gap-2">
<Button
size="sm"
color="secondary"
outline
on:click={() => onTest(channel)}
disabled={busy[channel.id ?? ""] === "test" || !channel.enabled}
title={$t("settings.notifications.channels.test")}
>
{#if busy[channel.id ?? ""] === "test"}
<Spinner size="sm" />
{:else}
<i class="bi bi-send"></i>
{/if}
</Button>
<Button
size="sm"
color="secondary"
outline
on:click={() => openEdit(channel)}
title={$t("settings.notifications.channels.edit")}
>
<i class="bi bi-pencil"></i>
</Button>
<Button
size="sm"
color="danger"
outline
on:click={() => onDelete(channel)}
disabled={busy[channel.id ?? ""] === "delete"}
title={$t("settings.notifications.channels.delete")}
>
{#if busy[channel.id ?? ""] === "delete"}
<Spinner size="sm" />
{:else}
<i class="bi bi-trash"></i>
{/if}
</Button>
</div>
</li>
{/each}
</ul>
{/if}
<ChannelEditor
open={editorOpen}
channelTypes={$notificationChannelTypes ?? []}
channel={editing}
onSave={onSaved}
onClose={() => (editorOpen = false)}
/>

View file

@ -0,0 +1,176 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import { Badge, Button, Spinner, Table } from "@sveltestrap/sveltestrap";
import { listHistory, type NotificationRecord } from "$lib/api/notifications";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
import {
getStatusColor,
getStatusI18nKey,
} from "$lib/utils/checkers";
let records: NotificationRecord[] = $state([]);
let loading: boolean = $state(true);
let limit: number = $state(50);
let loadingMore: boolean = $state(false);
async function refresh(newLimit?: number) {
const lim = newLimit ?? limit;
const isFirst = !newLimit;
if (isFirst) loading = true;
else loadingMore = true;
try {
records = await listHistory(lim);
limit = lim;
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.history.loadError"),
message: String(e),
timeout: 8000,
});
} finally {
loading = false;
loadingMore = false;
}
}
onMount(() => {
refresh();
});
function formatTarget(t: NotificationRecord["target"]): string {
if (!t) return "—";
const parts: string[] = [];
if (t.domainId) parts.push(`d:${t.domainId.slice(0, 8)}`);
if (t.serviceId) parts.push(`s:${t.serviceId.slice(0, 8)}`);
if (t.serviceType) parts.push(t.serviceType);
return parts.join(" / ") || "—";
}
function formatDate(d: Date | string | undefined): string {
if (!d) return "—";
try {
return d instanceof Date ? d.toLocaleString() : new Date(d).toLocaleString();
} catch {
return String(d);
}
}
</script>
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="mb-0 text-muted">
{$t("settings.notifications.history.description")}
</p>
<Button
color="secondary"
outline
size="sm"
on:click={() => refresh()}
disabled={loading || loadingMore}
title={$t("settings.notifications.history.refresh")}
>
<i class="bi bi-arrow-clockwise"></i>
</Button>
</div>
{#if loading}
<div class="d-flex justify-content-center py-3">
<Spinner color="primary" />
</div>
{:else if records.length === 0}
<div class="alert alert-secondary">
{$t("settings.notifications.history.empty")}
</div>
{:else}
<div class="table-responsive">
<Table size="sm" hover>
<thead>
<tr>
<th>{$t("settings.notifications.history.sentAt")}</th>
<th>{$t("settings.notifications.history.channel")}</th>
<th>{$t("settings.notifications.history.checker")}</th>
<th>{$t("settings.notifications.history.target")}</th>
<th>{$t("settings.notifications.history.transition")}</th>
<th>{$t("settings.notifications.history.result")}</th>
</tr>
</thead>
<tbody>
{#each records as r (r.id)}
<tr>
<td>{formatDate(r.sentAt)}</td>
<td>
<Badge color="info">{r.channelType}</Badge>
</td>
<td><code>{r.checkerId}</code></td>
<td><small>{formatTarget(r.target)}</small></td>
<td>
<Badge color={getStatusColor(r.oldStatus)}>
{$t(getStatusI18nKey(r.oldStatus))}
</Badge>
<Badge color={getStatusColor(r.newStatus)}>
{$t(getStatusI18nKey(r.newStatus))}
</Badge>
</td>
<td>
{#if r.success}
<Badge color="success">
<i class="bi bi-check-lg"></i>
{$t("settings.notifications.history.success")}
</Badge>
{:else}
<Badge color="danger" title={r.error}>
<i class="bi bi-x-lg"></i>
{$t("settings.notifications.history.failure")}
</Badge>
{#if r.error}
<div class="small text-muted">{r.error}</div>
{/if}
{/if}
</td>
</tr>
{/each}
</tbody>
</Table>
</div>
{#if records.length >= limit}
<div class="d-flex justify-content-center">
<Button
color="secondary"
outline
size="sm"
on:click={() => refresh(limit + 50)}
disabled={loadingMore}
>
{#if loadingMore}<Spinner size="sm" class="me-2" />{/if}
{$t("settings.notifications.history.loadMore")}
</Button>
</div>
{/if}
{/if}

View file

@ -0,0 +1,401 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { untrack } from "svelte";
import {
Button,
Input,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Spinner,
} from "@sveltestrap/sveltestrap";
import {
createPreference,
updatePreference,
type NotificationChannel,
type NotificationPreference,
} from "$lib/api/notifications";
import type { HappydnsDomainWithCheckStatus, HappydnsStatus } from "$lib/api-base/types.gen";
import { domains } from "$lib/stores/domains";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
import {
StatusOK,
StatusInfo,
StatusWarn,
StatusCrit,
StatusError,
} from "$lib/utils/checkers";
interface Props {
open: boolean;
preference: NotificationPreference | null;
channels: NotificationChannel[];
onSave: () => void;
onClose: () => void;
}
let { open, preference, channels, onSave, onClose }: Props = $props();
type Scope = "global" | "domain" | "service";
let scope: Scope = $state("global");
let domainId: string = $state("");
let serviceId: string = $state("");
let channelIds: string[] = $state([]);
let minStatus: HappydnsStatus = $state(StatusWarn);
let notifyRecovery: boolean = $state(true);
let enabled: boolean = $state(true);
let quietHoursEnabled: boolean = $state(false);
let quietStart: number = $state(22);
let quietEnd: number = $state(7);
let saving: boolean = $state(false);
$effect(() => {
if (!open) return;
untrack(() => {
const p = preference;
if (p) {
if (p.serviceId) {
scope = "service";
serviceId = p.serviceId;
domainId = p.domainId ?? "";
} else if (p.domainId) {
scope = "domain";
domainId = p.domainId;
serviceId = "";
} else {
scope = "global";
domainId = "";
serviceId = "";
}
channelIds = p.channelIds ? [...p.channelIds] : [];
minStatus = p.minStatus ?? StatusWarn;
notifyRecovery = p.notifyRecovery ?? true;
enabled = p.enabled ?? true;
if (
p.quietStart !== undefined &&
p.quietEnd !== undefined &&
p.quietStart !== null &&
p.quietEnd !== null
) {
quietHoursEnabled = true;
quietStart = p.quietStart;
quietEnd = p.quietEnd;
} else {
quietHoursEnabled = false;
quietStart = 22;
quietEnd = 7;
}
} else {
scope = "global";
domainId = "";
serviceId = "";
channelIds = [];
minStatus = StatusWarn;
notifyRecovery = true;
enabled = true;
quietHoursEnabled = false;
quietStart = 22;
quietEnd = 7;
}
});
});
function toggleChannel(id: string) {
channelIds = channelIds.includes(id)
? channelIds.filter((c) => c !== id)
: [...channelIds, id];
}
async function save() {
const payload: Parameters<typeof createPreference>[0] = {
channelIds: channelIds.length ? channelIds : undefined,
minStatus,
notifyRecovery,
enabled,
};
if (scope === "domain") {
if (!domainId) return;
payload.domainId = domainId;
} else if (scope === "service") {
if (!serviceId) return;
payload.serviceId = serviceId;
if (domainId) payload.domainId = domainId;
}
if (quietHoursEnabled) {
payload.quietStart = quietStart;
payload.quietEnd = quietEnd;
}
saving = true;
try {
if (preference?.id) {
await updatePreference(preference.id, payload);
} else {
await createPreference(payload);
}
toasts.addToast({
title: $t(
preference?.id
? "settings.notifications.preferences.updated"
: "settings.notifications.preferences.created",
),
timeout: 4000,
type: "success",
});
onSave();
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.preferences.saveError"),
message: String(e),
timeout: 8000,
});
} finally {
saving = false;
}
}
let domainList: HappydnsDomainWithCheckStatus[] = $derived($domains ?? []);
</script>
<Modal isOpen={open} toggle={onClose} size="lg">
<ModalHeader toggle={onClose}>
{preference?.id
? $t("settings.notifications.preferences.edit")
: $t("settings.notifications.preferences.add")}
</ModalHeader>
<ModalBody>
<form id="preference-editor-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
<fieldset class="mb-3">
<legend class="form-label">
{$t("settings.notifications.preferences.scope.label")}
</legend>
<div class="form-check">
<input
class="form-check-input"
type="radio"
id="scope-global"
name="scope"
value="global"
bind:group={scope}
/>
<label class="form-check-label" for="scope-global">
{$t("settings.notifications.preferences.scope.global")}
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
id="scope-domain"
name="scope"
value="domain"
bind:group={scope}
/>
<label class="form-check-label" for="scope-domain">
{$t("settings.notifications.preferences.scope.domain")}
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="radio"
id="scope-service"
name="scope"
value="service"
bind:group={scope}
/>
<label class="form-check-label" for="scope-service">
{$t("settings.notifications.preferences.scope.service")}
</label>
</div>
</fieldset>
{#if scope === "domain" || scope === "service"}
<div class="mb-3">
<label for="pref-domain" class="form-label">
{$t("settings.notifications.preferences.domain")}
</label>
<Input id="pref-domain" type="select" bind:value={domainId}>
<option value="">{$t("settings.notifications.preferences.selectDomain")}</option>
{#each domainList as d (d.id)}
<option value={d.id}>{d.domain}</option>
{/each}
</Input>
</div>
{/if}
{#if scope === "service"}
<div class="mb-3">
<label for="pref-service" class="form-label">
{$t("settings.notifications.preferences.serviceId")}
</label>
<Input id="pref-service" type="text" bind:value={serviceId} />
<small class="form-text text-muted">
{$t("settings.notifications.preferences.serviceIdHelp")}
</small>
</div>
{/if}
<div class="mb-3">
<label class="form-label" for="pref-channels">
{$t("settings.notifications.preferences.channels")}
</label>
{#if channels.length === 0}
<div class="alert alert-secondary mb-0 py-2">
{$t("settings.notifications.preferences.noChannels")}
</div>
{:else}
<div id="pref-channels">
{#each channels as c (c.id)}
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id={`pref-ch-${c.id}`}
checked={channelIds.includes(c.id ?? "")}
onchange={() => toggleChannel(c.id ?? "")}
/>
<label class="form-check-label" for={`pref-ch-${c.id}`}>
{c.name || c.type}
<span class="text-muted">({c.type})</span>
</label>
</div>
{/each}
</div>
<small class="form-text text-muted">
{$t("settings.notifications.preferences.channelsHelp")}
</small>
{/if}
</div>
<div class="mb-3">
<label for="pref-min-status" class="form-label">
{$t("settings.notifications.preferences.minStatus")}
</label>
<Input id="pref-min-status" type="select" bind:value={minStatus}>
<option value={StatusOK}>{$t("checkers.status.ok")}</option>
<option value={StatusInfo}>{$t("checkers.status.info")}</option>
<option value={StatusWarn}>{$t("checkers.status.warning")}</option>
<option value={StatusCrit}>{$t("checkers.status.critical")}</option>
<option value={StatusError}>{$t("checkers.status.error")}</option>
</Input>
<small class="form-text text-muted">
{$t("settings.notifications.preferences.minStatusHelp")}
</small>
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="pref-recovery"
bind:checked={notifyRecovery}
/>
<label class="form-check-label" for="pref-recovery">
{$t("settings.notifications.preferences.notifyRecovery")}
</label>
</div>
<div class="form-check form-switch mb-2">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="pref-quiet"
bind:checked={quietHoursEnabled}
/>
<label class="form-check-label" for="pref-quiet">
{$t("settings.notifications.preferences.quietHours")}
</label>
</div>
{#if quietHoursEnabled}
<div class="d-flex gap-2 mb-3 align-items-end">
<div>
<label for="pref-quiet-start" class="form-label">
{$t("settings.notifications.preferences.quietStart")}
</label>
<Input
id="pref-quiet-start"
type="number"
min="0"
max="23"
bind:value={quietStart}
/>
</div>
<div>
<label for="pref-quiet-end" class="form-label">
{$t("settings.notifications.preferences.quietEnd")}
</label>
<Input
id="pref-quiet-end"
type="number"
min="0"
max="23"
bind:value={quietEnd}
/>
</div>
<small class="form-text text-muted">
{$t("settings.notifications.preferences.quietHoursHelp")}
</small>
</div>
{/if}
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="pref-enabled"
bind:checked={enabled}
/>
<label class="form-check-label" for="pref-enabled">
{$t("settings.notifications.preferences.enabled")}
</label>
</div>
</form>
</ModalBody>
<ModalFooter>
<Button type="button" color="secondary" outline on:click={onClose} disabled={saving}>
{$t("common.cancel")}
</Button>
<Button
type="submit"
form="preference-editor-form"
color="primary"
disabled={saving ||
(scope === "domain" && !domainId) ||
(scope === "service" && !serviceId)}
>
{#if saving}<Spinner size="sm" class="me-2" />{/if}
{$t("common.save")}
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,258 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import { Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
import {
deletePreference,
listChannels,
listPreferences,
type NotificationChannel,
type NotificationPreference,
} from "$lib/api/notifications";
import { domains, refreshDomains } from "$lib/stores/domains";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
import { getStatusColor, getStatusI18nKey } from "$lib/utils/checkers";
import PreferenceEditor from "./PreferenceEditor.svelte";
let preferences: NotificationPreference[] = $state([]);
let channels: NotificationChannel[] = $state([]);
let loading: boolean = $state(true);
let editorOpen: boolean = $state(false);
let editing: NotificationPreference | null = $state(null);
let deleting: Record<string, boolean> = $state({});
async function refresh() {
loading = true;
try {
const [p, c] = await Promise.all([listPreferences(), listChannels()]);
preferences = p;
channels = c;
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.preferences.loadError"),
message: String(e),
timeout: 8000,
});
} finally {
loading = false;
}
}
onMount(() => {
refresh();
if (!$domains) refreshDomains().catch(() => {});
});
function openCreate() {
editing = null;
editorOpen = true;
}
function openEdit(pref: NotificationPreference) {
editing = pref;
editorOpen = true;
}
async function onDelete(pref: NotificationPreference) {
if (!pref.id) return;
if (!window.confirm($t("settings.notifications.preferences.confirmDelete"))) return;
deleting = { ...deleting, [pref.id]: true };
try {
await deletePreference(pref.id);
preferences = preferences.filter((p) => p.id !== pref.id);
toasts.addToast({
title: $t("settings.notifications.preferences.deleted"),
timeout: 4000,
type: "success",
});
} catch (e) {
toasts.addErrorToast({
title: $t("settings.notifications.preferences.deleteError"),
message: String(e),
timeout: 8000,
});
} finally {
const next = { ...deleting };
delete next[pref.id];
deleting = next;
}
}
function scopeLabel(p: NotificationPreference): string {
if (p.serviceId) {
const dom = $domains?.find((d) => d.id === p.domainId);
const domLabel = dom ? dom.domain : p.domainId ?? "?";
return $t("settings.notifications.preferences.scope.serviceLabel", {
domain: domLabel,
service: p.serviceId,
});
}
if (p.domainId) {
const dom = $domains?.find((d) => d.id === p.domainId);
return dom ? dom.domain : p.domainId;
}
return $t("settings.notifications.preferences.scope.global");
}
function channelsLabel(p: NotificationPreference): string {
if (!p.channelIds || p.channelIds.length === 0) {
return $t("settings.notifications.preferences.allChannels");
}
return p.channelIds
.map((id) => channels.find((c) => c.id === id)?.name || id)
.join(", ");
}
function onSaved() {
editorOpen = false;
refresh();
}
let hasGlobalPreference = $derived(
preferences.some((p) => !p.domainId && !p.serviceId && p.enabled),
);
</script>
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="mb-0 text-muted">
{$t("settings.notifications.preferences.description")}
</p>
<Button color="primary" size="sm" on:click={openCreate}>
<i class="bi bi-plus-lg"></i>
{$t("settings.notifications.preferences.add")}
</Button>
</div>
{#if loading}
<div class="d-flex justify-content-center py-3">
<Spinner color="primary" />
</div>
{:else}
{#if !hasGlobalPreference}
<div class="card mb-3 border-info">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2 mb-1">
<i class="bi bi-info-circle text-info"></i>
<strong>{$t("settings.notifications.preferences.defaults.title")}</strong>
</div>
<p class="mb-2 text-muted small">
{$t("settings.notifications.preferences.defaults.description")}
</p>
<ul class="list-unstyled mb-1 small">
<li>
<i class="bi bi-bell text-warning"></i>
{$t("settings.notifications.preferences.defaults.minStatus")}
</li>
<li>
<i class="bi bi-broadcast"></i>
{$t("settings.notifications.preferences.defaults.channels")}
</li>
<li>
<i class="bi bi-dash-circle text-secondary"></i>
{$t("settings.notifications.preferences.defaults.noRecovery")}
</li>
</ul>
<small class="text-muted fst-italic">
{$t("settings.notifications.preferences.defaults.override")}
</small>
</div>
</div>
{/if}
{#if preferences.length === 0}
<div class="alert alert-secondary">
{$t("settings.notifications.preferences.empty")}
</div>
{:else}
<ul class="list-group">
{#each preferences as pref (pref.id)}
<li class="list-group-item d-flex flex-column flex-md-row gap-2 align-items-md-center">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
<strong>{scopeLabel(pref)}</strong>
{#if !pref.enabled}
<Badge color="secondary">
{$t("settings.notifications.preferences.disabled")}
</Badge>
{/if}
<Badge color={getStatusColor(pref.minStatus)}>
{$t(getStatusI18nKey(pref.minStatus))}
</Badge>
{#if pref.notifyRecovery}
<Badge color="success">
<i class="bi bi-arrow-counterclockwise"></i>
{$t("settings.notifications.preferences.recovery")}
</Badge>
{/if}
{#if pref.quietStart !== undefined && pref.quietEnd !== undefined && pref.quietStart !== null && pref.quietEnd !== null}
<Badge color="info">
<i class="bi bi-moon"></i>
{pref.quietStart}h{pref.quietEnd}h UTC
</Badge>
{/if}
</div>
<small class="text-muted">{channelsLabel(pref)}</small>
</div>
<div class="d-flex gap-2">
<Button
size="sm"
color="secondary"
outline
on:click={() => openEdit(pref)}
title={$t("settings.notifications.preferences.edit")}
>
<i class="bi bi-pencil"></i>
</Button>
<Button
size="sm"
color="danger"
outline
on:click={() => onDelete(pref)}
disabled={deleting[pref.id ?? ""]}
title={$t("settings.notifications.preferences.delete")}
>
{#if deleting[pref.id ?? ""]}
<Spinner size="sm" />
{:else}
<i class="bi bi-trash"></i>
{/if}
</Button>
</div>
</li>
{/each}
</ul>
{/if}
{/if}
<PreferenceEditor
open={editorOpen}
preference={editing}
{channels}
onSave={onSaved}
onClose={() => (editorOpen = false)}
/>

View file

@ -0,0 +1,96 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Keep the set small — anything exotic falls through to the raw JSON editor.
export type FieldKind = "text" | "url" | "secret" | "headers";
export interface ChannelConfigField {
key: string;
kind: FieldKind;
required?: boolean;
i18nLabel: string;
i18nHelp?: string;
}
export interface ChannelConfigSchema {
fields: ChannelConfigField[];
}
// Field names mirror JSON tags in internal/notification/*_sender.go.
export const CHANNEL_CONFIG_SCHEMAS: Record<string, ChannelConfigSchema> = {
email: {
fields: [
{
key: "emailAddress",
kind: "text",
i18nLabel: "settings.notifications.channels.fields.emailAddress",
i18nHelp: "settings.notifications.channels.fields.emailAddressHelp",
},
],
},
webhook: {
fields: [
{
key: "webhookUrl",
kind: "url",
required: true,
i18nLabel: "settings.notifications.channels.fields.webhookUrl",
},
{
key: "webhookHeaders",
kind: "headers",
i18nLabel: "settings.notifications.channels.fields.webhookHeaders",
i18nHelp: "settings.notifications.channels.fields.webhookHeadersHelp",
},
{
key: "webhookSecret",
kind: "secret",
i18nLabel: "settings.notifications.channels.fields.webhookSecret",
i18nHelp: "settings.notifications.channels.fields.webhookSecretHelp",
},
],
},
unifiedpush: {
fields: [
{
key: "unifiedPushEndpoint",
kind: "url",
required: true,
i18nLabel: "settings.notifications.channels.fields.unifiedPushEndpoint",
i18nHelp: "settings.notifications.channels.fields.unifiedPushEndpointHelp",
},
],
},
};
export function getChannelConfigSchema(type: string | undefined): ChannelConfigSchema | undefined {
if (!type) return undefined;
return CHANNEL_CONFIG_SCHEMAS[type];
}
export function emptyConfigForSchema(schema: ChannelConfigSchema): Record<string, unknown> {
const cfg: Record<string, unknown> = {};
for (const f of schema.fields) {
if (f.kind === "headers") cfg[f.key] = {};
else cfg[f.key] = "";
}
return cfg;
}