orca_core/
notifications.rs1use serde::Serialize;
4use tracing::{info, warn};
5
6use crate::config::ObservabilityConfig;
7
8#[derive(Debug, Clone)]
10pub enum NotificationChannel {
11 Webhook { url: String },
13 Email {
15 smtp_host: String,
16 smtp_port: u16,
17 from: String,
18 to: String,
19 },
20}
21
22#[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 pub fn new(channels: Vec<NotificationChannel>) -> Self {
37 Self {
38 channels,
39 client: reqwest::Client::new(),
40 }
41 }
42
43 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 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 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}