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:
parent
dbd80e4f93
commit
78e8deb71c
4 changed files with 332 additions and 0 deletions
80
internal/notification/email_sender.go
Normal file
80
internal/notification/email_sender.go
Normal 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())
|
||||
}
|
||||
44
internal/notification/sender.go
Normal file
44
internal/notification/sender.go
Normal 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
|
||||
}
|
||||
90
internal/notification/unifiedpush_sender.go
Normal file
90
internal/notification/unifiedpush_sender.go
Normal 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
|
||||
}
|
||||
118
internal/notification/webhook_sender.go
Normal file
118
internal/notification/webhook_sender.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue