feat: implement backup notification system via email and webhooks

This commit is contained in:
2026-06-04 17:43:08 +00:00
parent 318f6c9b46
commit 76f2129a5b
4 changed files with 301 additions and 4 deletions
+42
View File
@@ -9,6 +9,8 @@ GoDump is a lightweight, standalone MariaDB backup application written in Go. It
- **Isolated Backups**: Each database is backed up via `mysqldump` in a dedicated subprocess, compressed instantly with `gzip`, and stored independently. Failure of one database does not disrupt others. - **Isolated Backups**: Each database is backed up via `mysqldump` in a dedicated subprocess, compressed instantly with `gzip`, and stored independently. Failure of one database does not disrupt others.
- **Retention Policies**: Configurable retention period (in days) per instance. Old backups are automatically groomed after every run. - **Retention Policies**: Configurable retention period (in days) per instance. Old backups are automatically groomed after every run.
- **Embedded Web UI**: Single-page modern interface served directly from the Go binary. No external CDN dependencies, fully functional offline. View statuses, trigger manual backups, browse backup files, and read real-time logs. - **Embedded Web UI**: Single-page modern interface served directly from the Go binary. No external CDN dependencies, fully functional offline. View statuses, trigger manual backups, browse backup files, and read real-time logs.
- **Optional Authentication**: Secure your dashboard and API with a simple, cookie-based session login.
- **Notifications**: Receive instant alerts when backup jobs complete via HTML Emails (SMTP) or JSON Webhooks (perfect for Ntfy, Gotify, Discord, Slack, Zapier, etc.).
- **Cron Scheduling**: Uses standard cron expressions to schedule automated jobs. - **Cron Scheduling**: Uses standard cron expressions to schedule automated jobs.
## Requirements ## Requirements
@@ -38,6 +40,41 @@ GoDump uses a YAML configuration file. By default, it looks for `/etc/godump/con
server: server:
port: 8080 port: 8080
auth:
enabled: true
username: admin
password: password
notifications:
events:
on_success: false
on_failure: true
email:
enabled: true
# You can override events at the channel level
# events:
# on_success: false
# on_failure: true
host: smtp.example.com
port: 587
username: myuser
password: mypassword
from: godump@example.com
to: you@example.com
webhooks:
- enabled: true
url: https://hook.example.com/success
events:
on_success: true
on_failure: false
- enabled: true
url: https://hook.example.com/failure
events:
on_success: false
on_failure: true
headers:
Authorization: "Bearer your_token_here"
logging: logging:
file: "" file: ""
@@ -62,6 +99,11 @@ instances:
``` ```
- `server.port`: The HTTP port for the web UI. - `server.port`: The HTTP port for the web UI.
- `auth`: Optional authentication for the Web UI. `enabled` to turn it on, along with `username` and `password`.
- `notifications`: Optional post-run notifications.
- `events`: Control what triggers notifications globally (`on_success`, `on_failure`).
- `email`: SMTP details for sending HTML-formatted email alerts. Can have its own `events` block.
- `webhooks`: An array of webhook endpoints. Each can have its own `events` block to fire only on specific outcomes.
- `logging.file`: The path where log files should be written. - `logging.file`: The path where log files should be written.
- `instances`: An array of MariaDB instances. Each requires its own name, connection details, backup directory, retention configuration (in days), and cron `schedule`. - `instances`: An array of MariaDB instances. Each requires its own name, connection details, backup directory, retention configuration (in days), and cron `schedule`.
+18
View File
@@ -8,6 +8,7 @@ import (
"godump/config" "godump/config"
"godump/logger" "godump/logger"
"godump/notify"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
) )
@@ -77,6 +78,7 @@ func (s *InstanceStatus) Snapshot() InstanceSnapshot {
} }
type Manager struct { type Manager struct {
cfg *config.Config
instances map[string]*InstanceStatus instances map[string]*InstanceStatus
cron *cron.Cron cron *cron.Cron
mu sync.RWMutex mu sync.RWMutex
@@ -87,6 +89,7 @@ func NewManager(cfg *config.Config) *Manager {
c.Start() c.Start()
m := &Manager{ m := &Manager{
cfg: cfg,
instances: make(map[string]*InstanceStatus), instances: make(map[string]*InstanceStatus),
cron: c, cron: c,
} }
@@ -305,7 +308,22 @@ func (m *Manager) RunInstance(name string) {
} else { } else {
inst.OverallResult = "partial" inst.OverallResult = "partial"
} }
payload := notify.Payload{
InstanceName: name,
OverallResult: inst.OverallResult,
Time: time.Now(),
}
for _, db := range dbs {
dbStat := inst.Databases[db]
payload.Databases = append(payload.Databases, notify.DBResult{
Name: dbStat.Name,
Size: dbStat.LastBackupSize,
Result: dbStat.LastBackupResult,
})
}
inst.mu.Unlock() inst.mu.Unlock()
logger.Info(name, "Backup job completed. Result: %s", inst.OverallResult) logger.Info(name, "Backup job completed. Result: %s", inst.OverallResult)
notify.Send(m.cfg.Notifications, payload)
} }
+46
View File
@@ -12,9 +12,40 @@ type AuthConfig struct {
Password string `yaml:"password"` Password string `yaml:"password"`
} }
type EmailConfig struct {
Enabled bool `yaml:"enabled"`
Events *NotificationEventsConfig `yaml:"events,omitempty"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
From string `yaml:"from"`
To string `yaml:"to"`
}
type WebhookConfig struct {
Enabled bool `yaml:"enabled"`
Events *NotificationEventsConfig `yaml:"events,omitempty"`
URL string `yaml:"url"`
Headers map[string]string `yaml:"headers"`
}
type NotificationEventsConfig struct {
OnSuccess bool `yaml:"on_success"`
OnFailure bool `yaml:"on_failure"`
}
type NotificationsConfig struct {
Events NotificationEventsConfig `yaml:"events"`
Email EmailConfig `yaml:"email"`
Webhook WebhookConfig `yaml:"webhook"`
Webhooks []WebhookConfig `yaml:"webhooks"`
}
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"` Logging LoggingConfig `yaml:"logging"`
Instances []InstanceConfig `yaml:"instances"` Instances []InstanceConfig `yaml:"instances"`
} }
@@ -62,6 +93,21 @@ func GenerateDefaultConfig(path string) error {
Username: "admin", Username: "admin",
Password: "password", Password: "password",
}, },
Notifications: NotificationsConfig{
Events: NotificationEventsConfig{
OnSuccess: false,
OnFailure: true,
},
Email: EmailConfig{
Enabled: false,
Host: "smtp.example.com",
Port: 587,
},
Webhook: WebhookConfig{
Enabled: false,
URL: "https://hook.example.com",
},
},
Logging: LoggingConfig{ Logging: LoggingConfig{
File: "", File: "",
}, },
+191
View File
@@ -0,0 +1,191 @@
package notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/smtp"
"text/template"
"time"
"godump/config"
"godump/logger"
)
type DBResult struct {
Name string `json:"name"`
Size int64 `json:"size"`
Result string `json:"result"`
}
type Payload struct {
InstanceName string `json:"instance_name"`
OverallResult string `json:"overall_result"`
Time time.Time `json:"time"`
Databases []DBResult `json:"databases"`
}
func shouldSend(global config.NotificationEventsConfig, local *config.NotificationEventsConfig, result string) bool {
events := global
if local != nil {
events = *local
}
if result == "success" && !events.OnSuccess {
return false
}
if (result == "failed" || result == "partial") && !events.OnFailure {
return false
}
return true
}
func Send(cfg config.NotificationsConfig, payload Payload) {
if cfg.Webhook.Enabled && shouldSend(cfg.Events, cfg.Webhook.Events, payload.OverallResult) {
go sendWebhook(cfg.Webhook, payload)
}
for _, w := range cfg.Webhooks {
if w.Enabled && shouldSend(cfg.Events, w.Events, payload.OverallResult) {
go sendWebhook(w, payload)
}
}
if cfg.Email.Enabled && shouldSend(cfg.Events, cfg.Email.Events, payload.OverallResult) {
go sendEmail(cfg.Email, payload)
}
}
func sendWebhook(cfg config.WebhookConfig, payload Payload) {
message := fmt.Sprintf("**GoDump Backup Result**\nInstance: %s\nResult: %s\nTime: %s\n\nDatabases:\n",
payload.InstanceName, payload.OverallResult, payload.Time.Format("2006-01-02 15:04:05"))
for _, db := range payload.Databases {
message += fmt.Sprintf("- %s: %s (%s)\n", db.Name, db.Result, formatBytes(db.Size))
}
webhookPayload := map[string]interface{}{
"content": message,
"text": message,
"title": fmt.Sprintf("GoDump Backup Result: %s", payload.InstanceName),
"message": message,
}
data, err := json.Marshal(webhookPayload)
if err != nil {
logger.Error(payload.InstanceName, "Failed to marshal webhook payload: %v", err)
return
}
req, err := http.NewRequest("POST", cfg.URL, bytes.NewBuffer(data))
if err != nil {
logger.Error(payload.InstanceName, "Failed to create webhook request: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
for k, v := range cfg.Headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
logger.Error(payload.InstanceName, "Failed to send webhook: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
logger.Error(payload.InstanceName, "Webhook returned error status: %d", resp.StatusCode)
} else {
logger.Info(payload.InstanceName, "Webhook notification sent successfully")
}
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
const emailTmplStr = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; background-color: #0a0a0a; color: #f0f0f0; margin: 0; padding: 20px; }
.card { background-color: #141414; border: 1px solid #2a2a2a; border-radius: 8px; padding: 20px; max-width: 600px; margin: 0 auto; }
h2 { margin-top: 0; }
.accent { color: #21D198; }
.error { color: #ef4444; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #2a2a2a; }
th { color: #888888; text-transform: uppercase; font-size: 12px; }
</style>
</head>
<body>
<div class="card">
<h2>GoDump - Instance: <span class="accent">{{.InstanceName}}</span></h2>
<p>Overall Result: <strong class="{{if eq .OverallResult "success"}}accent{{else}}error{{end}}">{{.OverallResult}}</strong></p>
<p>Time: {{.Time.Format "2006-01-02 15:04:05"}}</p>
<table>
<thead>
<tr>
<th>Database</th>
<th>Size</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{{range .Databases}}
<tr>
<td>{{.Name}}</td>
<td>{{formatBytes .Size}}</td>
<td class="{{if eq .Result "success"}}accent{{else}}error{{end}}">{{.Result}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</body>
</html>
`
func sendEmail(cfg config.EmailConfig, payload Payload) {
tmpl, err := template.New("email").Funcs(template.FuncMap{"formatBytes": formatBytes}).Parse(emailTmplStr)
if err != nil {
logger.Error(payload.InstanceName, "Failed to parse email template: %v", err)
return
}
var body bytes.Buffer
if err := tmpl.Execute(&body, payload); err != nil {
logger.Error(payload.InstanceName, "Failed to execute email template: %v", err)
return
}
subject := fmt.Sprintf("Subject: GoDump Backup Result: %s [%s]\r\n", payload.InstanceName, payload.OverallResult)
mime := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
msg := []byte(fmt.Sprintf("To: %s\r\nFrom: GoDump <%s>\r\n%s%s\r\n%s", cfg.To, cfg.From, subject, mime, body.String()))
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
err = smtp.SendMail(addr, auth, cfg.From, []string{cfg.To}, msg)
if err != nil {
logger.Error(payload.InstanceName, "Failed to send email: %v", err)
} else {
logger.Info(payload.InstanceName, "Email notification sent successfully")
}
}