notification: add channel senders for email, webhook, and UnifiedPush

Implement ChannelSender interface with three backends:
- EmailSender: reuses existing Mailer, sends Markdown-formatted alerts
- WebhookSender: HTTP POST with JSON payload and optional HMAC signature
- UnifiedPushSender: HTTP POST following the UnifiedPush protocol

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-04-11 08:44:36 +07:00
commit 78e8deb71c
4 changed files with 332 additions and 0 deletions

View file

@ -0,0 +1,80 @@
// 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"
"net/mail"
"strings"
"git.happydns.org/happyDomain/model"
)
// EmailSender sends notifications via email using the existing Mailer.
type EmailSender struct {
mailer happydns.Mailer
}
// NewEmailSender creates a new EmailSender.
func NewEmailSender(mailer happydns.Mailer) *EmailSender {
return &EmailSender{mailer: mailer}
}
func (s *EmailSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
addr := channel.Config.EmailAddress
if addr == "" && payload.User != nil {
addr = payload.User.Email
}
if addr == "" {
return fmt.Errorf("no email address configured for channel %s", channel.Id)
}
to := &mail.Address{Address: addr}
if payload.User != nil {
to.Name = payload.User.Email
}
subject := fmt.Sprintf("[happyDomain] %s: %s", payload.DomainName, payload.NewStatus)
var body strings.Builder
fmt.Fprintf(&body, "## Status Change: %s -> %s\n\n", payload.OldStatus, payload.NewStatus)
fmt.Fprintf(&body, "**Domain:** %s\n\n", payload.DomainName)
fmt.Fprintf(&body, "**Checker:** %s\n\n", 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", state.Code, state.Status, state.Message)
}
body.WriteString("\n")
}
if payload.Annotation != "" {
fmt.Fprintf(&body, "**Note:** %s\n\n", payload.Annotation)
}
if payload.BaseURL != "" {
fmt.Fprintf(&body, "[View in happyDomain](%s)\n", payload.BaseURL)
}
return s.mailer.SendMail(to, subject, body.String())
}

View file

@ -0,0 +1,44 @@
// 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 (
"git.happydns.org/happyDomain/model"
)
// NotificationPayload holds the data passed to channel senders.
type NotificationPayload struct {
User *happydns.User
CheckerID string
Target happydns.CheckTarget
DomainName string
OldStatus happydns.Status
NewStatus happydns.Status
States []happydns.CheckState
Annotation string
BaseURL string
}
// ChannelSender sends a notification through a specific channel type.
type ChannelSender interface {
Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error
}

View file

@ -0,0 +1,90 @@
// 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"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
// UnifiedPushSender sends notifications via the UnifiedPush protocol.
type UnifiedPushSender struct {
client *http.Client
}
// NewUnifiedPushSender creates a new UnifiedPushSender.
func NewUnifiedPushSender() *UnifiedPushSender {
return &UnifiedPushSender{
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *UnifiedPushSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
if channel.Config.UnifiedPushEndpoint == "" {
return fmt.Errorf("no UnifiedPush endpoint configured for channel %s", channel.Id)
}
msg := WebhookPayload{
Event: "status_change",
Checker: payload.CheckerID,
Domain: payload.DomainName,
Target: payload.Target,
OldStatus: payload.OldStatus,
NewStatus: payload.NewStatus,
States: payload.States,
Timestamp: time.Now(),
}
if payload.BaseURL != "" {
msg.DashboardURL = payload.BaseURL
}
body, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshaling UnifiedPush payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, channel.Config.UnifiedPushEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating UnifiedPush request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("sending UnifiedPush notification: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("UnifiedPush endpoint returned status %d", resp.StatusCode)
}
return nil
}

View file

@ -0,0 +1,118 @@
// 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"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
// WebhookPayload is the JSON body sent to webhook endpoints.
type WebhookPayload 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"`
}
// WebhookSender sends notifications via HTTP POST to a configured URL.
type WebhookSender struct {
client *http.Client
}
// NewWebhookSender creates a new WebhookSender.
func NewWebhookSender() *WebhookSender {
return &WebhookSender{
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *WebhookSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
if channel.Config.WebhookURL == "" {
return fmt.Errorf("no webhook URL configured for channel %s", channel.Id)
}
whPayload := WebhookPayload{
Event: "status_change",
Checker: payload.CheckerID,
Domain: payload.DomainName,
Target: payload.Target,
OldStatus: payload.OldStatus,
NewStatus: payload.NewStatus,
States: payload.States,
Timestamp: time.Now(),
}
if payload.BaseURL != "" {
whPayload.DashboardURL = payload.BaseURL
}
body, err := json.Marshal(whPayload)
if err != nil {
return fmt.Errorf("marshaling webhook payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, channel.Config.WebhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "happyDomain-Notification/1.0")
for k, v := range channel.Config.WebhookHeaders {
req.Header.Set(k, v)
}
if channel.Config.WebhookSecret != "" {
mac := hmac.New(sha256.New, []byte(channel.Config.WebhookSecret))
mac.Write(body)
sig := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Happydomain-Signature", "sha256="+sig)
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("sending webhook: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}