diff --git a/internal/notification/email_sender.go b/internal/notification/email_sender.go new file mode 100644 index 00000000..ae270cd8 --- /dev/null +++ b/internal/notification/email_sender.go @@ -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 . +// +// 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 . + +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()) +} diff --git a/internal/notification/sender.go b/internal/notification/sender.go new file mode 100644 index 00000000..1c7e1bc6 --- /dev/null +++ b/internal/notification/sender.go @@ -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 . +// +// 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 . + +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 +} diff --git a/internal/notification/unifiedpush_sender.go b/internal/notification/unifiedpush_sender.go new file mode 100644 index 00000000..bf9435e9 --- /dev/null +++ b/internal/notification/unifiedpush_sender.go @@ -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 . +// +// 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 . + +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 +} diff --git a/internal/notification/webhook_sender.go b/internal/notification/webhook_sender.go new file mode 100644 index 00000000..6e273a68 --- /dev/null +++ b/internal/notification/webhook_sender.go @@ -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 . +// +// 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 . + +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 +}