Skip to content

Commit ed752fc

Browse files
committed
feat: add trends, download/upload, dns validator & alerting
Release-As: 1.5.0
1 parent 417a3ab commit ed752fc

File tree

20 files changed

+2859
-67
lines changed

20 files changed

+2859
-67
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,7 @@ ignore
6363
.envrc*
6464
db.sqlite*
6565
*~
66+
.agents
67+
.claude
68+
.direnv
69+
docs/plans/

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/alerting/alerting.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Package alerting provides email and webhook alerting for DMARC compliance changes.
2+
package alerting
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"fmt"
8+
"net/http"
9+
"net/smtp"
10+
"time"
11+
12+
"github.com/goccy/go-json"
13+
"github.com/rs/zerolog"
14+
15+
"github.com/meysam81/parse-dmarc/internal/storage"
16+
)
17+
18+
// Config holds alerting configuration.
19+
type Config struct {
20+
Enabled bool `json:"enabled" env:"ALERTING_ENABLED"`
21+
ComplianceThreshold float64 `json:"compliance_threshold" env:"ALERTING_COMPLIANCE_THRESHOLD"` // Alert when compliance drops below this (default 90)
22+
VolumeChangePercent float64 `json:"volume_change_percent" env:"ALERTING_VOLUME_CHANGE_PERCENT"` // Alert when volume changes by this % (default 50)
23+
CooldownMinutes int `json:"cooldown_minutes" env:"ALERTING_COOLDOWN_MINUTES"` // Min minutes between same alert type (default 60)
24+
25+
// Email (SMTP) settings
26+
SMTPHost string `json:"smtp_host" env:"ALERTING_SMTP_HOST"`
27+
SMTPPort int `json:"smtp_port" env:"ALERTING_SMTP_PORT"`
28+
SMTPUsername string `json:"smtp_username" env:"ALERTING_SMTP_USERNAME"`
29+
SMTPPassword string `json:"smtp_password" env:"ALERTING_SMTP_PASSWORD"`
30+
SMTPFrom string `json:"smtp_from" env:"ALERTING_SMTP_FROM"`
31+
EmailTo []string `json:"email_to" env:"ALERTING_EMAIL_TO" envSeparator:","`
32+
33+
// Webhook settings
34+
WebhookURLs []string `json:"webhook_urls" env:"ALERTING_WEBHOOK_URLS" envSeparator:","`
35+
}
36+
37+
// Alert represents an alert event.
38+
type Alert struct {
39+
Type string `json:"type"` // compliance_drop, new_source, volume_spike
40+
Severity string `json:"severity"` // info, warning, critical
41+
Title string `json:"title"`
42+
Message string `json:"message"`
43+
Details map[string]interface{} `json:"details,omitempty"`
44+
Timestamp time.Time `json:"timestamp"`
45+
}
46+
47+
// Alerter manages alert evaluation and delivery.
48+
type Alerter struct {
49+
config *Config
50+
store *storage.Storage
51+
log *zerolog.Logger
52+
lastAlerts map[string]time.Time // type -> last sent time
53+
}
54+
55+
// New creates a new Alerter.
56+
func New(cfg *Config, store *storage.Storage, log *zerolog.Logger) *Alerter {
57+
if cfg.ComplianceThreshold == 0 {
58+
cfg.ComplianceThreshold = 90
59+
}
60+
if cfg.VolumeChangePercent == 0 {
61+
cfg.VolumeChangePercent = 50
62+
}
63+
if cfg.CooldownMinutes == 0 {
64+
cfg.CooldownMinutes = 60
65+
}
66+
67+
return &Alerter{
68+
config: cfg,
69+
store: store,
70+
log: log,
71+
lastAlerts: make(map[string]time.Time),
72+
}
73+
}
74+
75+
// Evaluate checks current data against alert thresholds and sends alerts if needed.
76+
func (a *Alerter) Evaluate(ctx context.Context) {
77+
if !a.config.Enabled {
78+
return
79+
}
80+
81+
stats, err := a.store.GetStatistics()
82+
if err != nil || !stats.HasData {
83+
return
84+
}
85+
86+
// Check compliance threshold
87+
if stats.ComplianceRate < a.config.ComplianceThreshold {
88+
a.sendIfCooldown("compliance_drop", Alert{
89+
Type: "compliance_drop",
90+
Severity: severity(stats.ComplianceRate, 80, 50),
91+
Title: "DMARC Compliance Below Threshold",
92+
Message: fmt.Sprintf(
93+
"Compliance rate is %.1f%%, below the %.0f%% threshold.",
94+
stats.ComplianceRate, a.config.ComplianceThreshold,
95+
),
96+
Details: map[string]interface{}{
97+
"compliance_rate": stats.ComplianceRate,
98+
"threshold": a.config.ComplianceThreshold,
99+
"total_messages": stats.TotalMessages,
100+
},
101+
Timestamp: time.Now(),
102+
})
103+
}
104+
}
105+
106+
func severity(rate, warnThreshold, critThreshold float64) string {
107+
if rate < critThreshold {
108+
return "critical"
109+
}
110+
if rate < warnThreshold {
111+
return "warning"
112+
}
113+
return "info"
114+
}
115+
116+
func (a *Alerter) sendIfCooldown(alertType string, alert Alert) {
117+
cooldown := time.Duration(a.config.CooldownMinutes) * time.Minute
118+
if last, ok := a.lastAlerts[alertType]; ok && time.Since(last) < cooldown {
119+
return
120+
}
121+
122+
a.lastAlerts[alertType] = time.Now()
123+
124+
// Send via all configured channels
125+
if len(a.config.EmailTo) > 0 && a.config.SMTPHost != "" {
126+
if err := a.sendEmail(alert); err != nil {
127+
a.log.Error().Err(err).Str("type", alertType).Msg("failed to send email alert")
128+
}
129+
}
130+
131+
for _, url := range a.config.WebhookURLs {
132+
if err := a.sendWebhook(url, alert); err != nil {
133+
a.log.Error().Err(err).Str("url", url).Str("type", alertType).Msg("failed to send webhook alert")
134+
}
135+
}
136+
137+
a.log.Info().Str("type", alertType).Str("severity", alert.Severity).Msg("alert sent")
138+
}
139+
140+
func (a *Alerter) sendEmail(alert Alert) error {
141+
subject := fmt.Sprintf("[Parse DMARC] %s: %s", alert.Severity, alert.Title)
142+
body := fmt.Sprintf("Subject: %s\r\nFrom: %s\r\nContent-Type: text/plain\r\n\r\n%s\r\n\r\nSeverity: %s\r\nTime: %s\r\n",
143+
subject,
144+
a.config.SMTPFrom,
145+
alert.Message,
146+
alert.Severity,
147+
alert.Timestamp.Format(time.RFC3339),
148+
)
149+
150+
addr := fmt.Sprintf("%s:%d", a.config.SMTPHost, a.config.SMTPPort)
151+
var auth smtp.Auth
152+
if a.config.SMTPUsername != "" {
153+
auth = smtp.PlainAuth("", a.config.SMTPUsername, a.config.SMTPPassword, a.config.SMTPHost)
154+
}
155+
156+
return smtp.SendMail(addr, auth, a.config.SMTPFrom, a.config.EmailTo, []byte(body))
157+
}
158+
159+
func (a *Alerter) sendWebhook(url string, alert Alert) error {
160+
payload, err := json.Marshal(alert)
161+
if err != nil {
162+
return err
163+
}
164+
165+
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
166+
if err != nil {
167+
return err
168+
}
169+
req.Header.Set("Content-Type", "application/json")
170+
req.Header.Set("User-Agent", "parse-dmarc-alerting/1.0")
171+
172+
client := &http.Client{Timeout: 10 * time.Second}
173+
resp, err := client.Do(req)
174+
if err != nil {
175+
return err
176+
}
177+
defer func() { _ = resp.Body.Close() }()
178+
179+
if resp.StatusCode >= 400 {
180+
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
181+
}
182+
183+
return nil
184+
}

0 commit comments

Comments
 (0)