Skip to main content

orca_core/
notifications.rs

1//! Notification dispatch to Slack, Discord, and email channels.
2
3use serde::Serialize;
4use tracing::{info, warn};
5
6use crate::config::ObservabilityConfig;
7
8/// A notification channel target.
9#[derive(Debug, Clone)]
10pub enum NotificationChannel {
11    /// Slack/Discord-compatible webhook.
12    Webhook { url: String },
13    /// Email notification (SMTP delivery deferred to M5).
14    Email {
15        smtp_host: String,
16        smtp_port: u16,
17        from: String,
18        to: String,
19    },
20}
21
22/// Dispatches notifications to all configured channels.
23#[derive(Debug, Clone)]
24pub struct Notifier {
25    channels: Vec<NotificationChannel>,
26    client: reqwest::Client,
27}
28
29#[derive(Serialize)]
30struct WebhookPayload {
31    text: String,
32}
33
34impl Notifier {
35    /// Create a notifier with the given channels.
36    pub fn new(channels: Vec<NotificationChannel>) -> Self {
37        Self {
38            channels,
39            client: reqwest::Client::new(),
40        }
41    }
42
43    /// Build a notifier from cluster observability config.
44    ///
45    /// Reads `observability.alerts.webhook` and `observability.alerts.email` fields.
46    pub fn from_config(config: &ObservabilityConfig) -> Self {
47        let mut channels = Vec::new();
48
49        if let Some(ref alerts) = config.alerts {
50            if let Some(ref url) = alerts.webhook {
51                channels.push(NotificationChannel::Webhook { url: url.clone() });
52            }
53            if let Some(ref email) = alerts.email {
54                channels.push(NotificationChannel::Email {
55                    smtp_host: "localhost".into(),
56                    smtp_port: 587,
57                    from: "orca@localhost".into(),
58                    to: email.clone(),
59                });
60            }
61        }
62
63        Self::new(channels)
64    }
65
66    /// Send a notification to all configured channels.
67    ///
68    /// `severity` is informational (e.g. "info", "warning", "critical").
69    /// Failures on individual channels are logged but do not abort the remaining sends.
70    pub async fn send(&self, title: &str, message: &str, severity: &str) {
71        for channel in &self.channels {
72            match channel {
73                NotificationChannel::Webhook { url } => {
74                    self.send_webhook(url, title, message, severity).await;
75                }
76                NotificationChannel::Email { to, .. } => {
77                    info!(
78                        to = %to,
79                        title = %title,
80                        severity = %severity,
81                        "would send email to {} — SMTP delivery deferred to M5",
82                        to
83                    );
84                }
85            }
86        }
87    }
88
89    async fn send_webhook(&self, url: &str, title: &str, message: &str, severity: &str) {
90        let payload = WebhookPayload {
91            text: format!("[{severity}] {title}: {message}"),
92        };
93
94        match self.client.post(url).json(&payload).send().await {
95            Ok(resp) => {
96                if resp.status().is_success() {
97                    info!(url = %url, "notification sent via webhook");
98                } else {
99                    warn!(
100                        url = %url,
101                        status = %resp.status(),
102                        "webhook returned non-success status"
103                    );
104                }
105            }
106            Err(e) => {
107                warn!(url = %url, error = %e, "failed to send webhook notification");
108            }
109        }
110    }
111
112    /// Returns the number of configured channels.
113    pub fn channel_count(&self) -> usize {
114        self.channels.len()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn from_config_with_webhook() {
124        use crate::config::{AlertChannelConfig, ObservabilityConfig};
125
126        let config = ObservabilityConfig {
127            otlp_endpoint: None,
128            alerts: Some(AlertChannelConfig {
129                webhook: Some("https://hooks.slack.com/test".into()),
130                email: Some("ops@example.com".into()),
131            }),
132        };
133
134        let notifier = Notifier::from_config(&config);
135        assert_eq!(notifier.channel_count(), 2);
136    }
137
138    #[test]
139    fn from_config_no_alerts() {
140        let config = ObservabilityConfig {
141            otlp_endpoint: None,
142            alerts: None,
143        };
144
145        let notifier = Notifier::from_config(&config);
146        assert_eq!(notifier.channel_count(), 0);
147    }
148
149    #[test]
150    fn empty_notifier() {
151        let notifier = Notifier::new(vec![]);
152        assert_eq!(notifier.channel_count(), 0);
153    }
154}