ferro_notifications/
dispatcher.rs

1//! Notification dispatcher for sending notifications through channels.
2
3use crate::channel::Channel;
4use crate::channels::{MailMessage, SlackMessage};
5use crate::notifiable::Notifiable;
6use crate::notification::Notification;
7use crate::Error;
8use std::env;
9use std::sync::OnceLock;
10use tracing::{error, info};
11
12/// Global notification dispatcher configuration.
13static CONFIG: OnceLock<NotificationConfig> = OnceLock::new();
14
15/// Configuration for the notification dispatcher.
16#[derive(Clone, Default)]
17pub struct NotificationConfig {
18    /// SMTP configuration for mail notifications.
19    pub mail: Option<MailConfig>,
20    /// Slack webhook URL.
21    pub slack_webhook: Option<String>,
22}
23
24/// SMTP mail configuration.
25#[derive(Clone)]
26pub struct MailConfig {
27    /// SMTP host.
28    pub host: String,
29    /// SMTP port.
30    pub port: u16,
31    /// SMTP username.
32    pub username: Option<String>,
33    /// SMTP password.
34    pub password: Option<String>,
35    /// Default from address.
36    pub from: String,
37    /// Default from name.
38    pub from_name: Option<String>,
39    /// Use TLS.
40    pub tls: bool,
41}
42
43impl NotificationConfig {
44    /// Create a new notification config.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Create configuration from environment variables.
50    ///
51    /// Reads the following environment variables:
52    /// - Mail: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`,
53    ///   `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME`, `MAIL_ENCRYPTION`
54    /// - Slack: `SLACK_WEBHOOK_URL`
55    ///
56    /// # Example
57    ///
58    /// ```rust,ignore
59    /// use cancer_notifications::NotificationConfig;
60    ///
61    /// // In bootstrap.rs
62    /// let config = NotificationConfig::from_env();
63    /// NotificationDispatcher::configure(config);
64    /// ```
65    pub fn from_env() -> Self {
66        Self {
67            mail: MailConfig::from_env(),
68            slack_webhook: env::var("SLACK_WEBHOOK_URL").ok().filter(|s| !s.is_empty()),
69        }
70    }
71
72    /// Set the mail configuration.
73    pub fn mail(mut self, config: MailConfig) -> Self {
74        self.mail = Some(config);
75        self
76    }
77
78    /// Set the Slack webhook URL.
79    pub fn slack_webhook(mut self, url: impl Into<String>) -> Self {
80        self.slack_webhook = Some(url.into());
81        self
82    }
83}
84
85impl MailConfig {
86    /// Create a new mail config.
87    pub fn new(host: impl Into<String>, port: u16, from: impl Into<String>) -> Self {
88        Self {
89            host: host.into(),
90            port,
91            username: None,
92            password: None,
93            from: from.into(),
94            from_name: None,
95            tls: true,
96        }
97    }
98
99    /// Create mail configuration from environment variables.
100    ///
101    /// Returns `None` if `MAIL_HOST` is not set.
102    ///
103    /// Reads the following environment variables:
104    /// - `MAIL_HOST`: SMTP server host (required)
105    /// - `MAIL_PORT`: SMTP server port (default: 587)
106    /// - `MAIL_USERNAME`: SMTP username (optional)
107    /// - `MAIL_PASSWORD`: SMTP password (optional)
108    /// - `MAIL_FROM_ADDRESS`: Default from email address (required)
109    /// - `MAIL_FROM_NAME`: Default from name (optional)
110    /// - `MAIL_ENCRYPTION`: "tls" or "none" (default: "tls")
111    ///
112    /// # Example
113    ///
114    /// ```rust,ignore
115    /// use cancer_notifications::MailConfig;
116    ///
117    /// if let Some(config) = MailConfig::from_env() {
118    ///     // Mail is configured
119    /// }
120    /// ```
121    pub fn from_env() -> Option<Self> {
122        let host = env::var("MAIL_HOST").ok().filter(|s| !s.is_empty())?;
123        let from = env::var("MAIL_FROM_ADDRESS")
124            .ok()
125            .filter(|s| !s.is_empty())?;
126
127        let port = env::var("MAIL_PORT")
128            .ok()
129            .and_then(|p| p.parse().ok())
130            .unwrap_or(587);
131
132        let username = env::var("MAIL_USERNAME").ok().filter(|s| !s.is_empty());
133        let password = env::var("MAIL_PASSWORD").ok().filter(|s| !s.is_empty());
134        let from_name = env::var("MAIL_FROM_NAME").ok().filter(|s| !s.is_empty());
135
136        let tls = env::var("MAIL_ENCRYPTION")
137            .map(|v| v.to_lowercase() != "none")
138            .unwrap_or(true);
139
140        Some(Self {
141            host,
142            port,
143            username,
144            password,
145            from,
146            from_name,
147            tls,
148        })
149    }
150
151    /// Set SMTP credentials.
152    pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
153        self.username = Some(username.into());
154        self.password = Some(password.into());
155        self
156    }
157
158    /// Set the from name.
159    pub fn from_name(mut self, name: impl Into<String>) -> Self {
160        self.from_name = Some(name.into());
161        self
162    }
163
164    /// Disable TLS.
165    pub fn no_tls(mut self) -> Self {
166        self.tls = false;
167        self
168    }
169}
170
171/// The notification dispatcher.
172pub struct NotificationDispatcher;
173
174impl NotificationDispatcher {
175    /// Configure the global notification dispatcher.
176    pub fn configure(config: NotificationConfig) {
177        let _ = CONFIG.set(config);
178    }
179
180    /// Get the current configuration.
181    pub fn config() -> Option<&'static NotificationConfig> {
182        CONFIG.get()
183    }
184
185    /// Send a notification to a notifiable entity.
186    pub async fn send<N, T>(notifiable: &N, notification: T) -> Result<(), Error>
187    where
188        N: Notifiable + ?Sized,
189        T: Notification,
190    {
191        let channels = notification.via();
192        let notification_type = notification.notification_type();
193
194        info!(
195            notification = notification_type,
196            channels = ?channels,
197            "Dispatching notification"
198        );
199
200        for channel in channels {
201            match channel {
202                Channel::Mail => {
203                    if let Some(mail) = notification.to_mail() {
204                        Self::send_mail(notifiable, &mail).await?;
205                    }
206                }
207                Channel::Database => {
208                    if let Some(db_msg) = notification.to_database() {
209                        Self::send_database(notifiable, &db_msg).await?;
210                    }
211                }
212                Channel::Slack => {
213                    if let Some(slack) = notification.to_slack() {
214                        Self::send_slack(notifiable, &slack).await?;
215                    }
216                }
217                Channel::Sms | Channel::Push => {
218                    // Not implemented yet
219                    info!(channel = %channel, "Channel not implemented");
220                }
221            }
222        }
223
224        Ok(())
225    }
226
227    /// Send a mail notification.
228    async fn send_mail<N: Notifiable + ?Sized>(
229        notifiable: &N,
230        message: &MailMessage,
231    ) -> Result<(), Error> {
232        let to = notifiable
233            .route_notification_for(Channel::Mail)
234            .ok_or_else(|| Error::ChannelNotAvailable("No mail route configured".into()))?;
235
236        let config = CONFIG
237            .get()
238            .and_then(|c| c.mail.as_ref())
239            .ok_or_else(|| Error::ChannelNotAvailable("Mail not configured".into()))?;
240
241        info!(to = %to, subject = %message.subject, "Sending mail notification");
242
243        // Build the email
244        use lettre::message::{header::ContentType, Mailbox};
245        use lettre::transport::smtp::authentication::Credentials;
246        use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
247
248        let from: Mailbox = if let Some(ref name) = config.from_name {
249            format!("{} <{}>", name, config.from)
250                .parse()
251                .map_err(|e| Error::mail(format!("Invalid from address: {}", e)))?
252        } else {
253            config
254                .from
255                .parse()
256                .map_err(|e| Error::mail(format!("Invalid from address: {}", e)))?
257        };
258
259        let to_mailbox: Mailbox = to
260            .parse()
261            .map_err(|e| Error::mail(format!("Invalid to address: {}", e)))?;
262
263        let mut email_builder = Message::builder()
264            .from(from)
265            .to(to_mailbox)
266            .subject(&message.subject);
267
268        // Add reply-to if specified
269        if let Some(ref reply_to) = message.reply_to {
270            let reply_to_mailbox: Mailbox = reply_to
271                .parse()
272                .map_err(|e| Error::mail(format!("Invalid reply-to address: {}", e)))?;
273            email_builder = email_builder.reply_to(reply_to_mailbox);
274        }
275
276        // Add CC recipients
277        for cc in &message.cc {
278            let cc_mailbox: Mailbox = cc
279                .parse()
280                .map_err(|e| Error::mail(format!("Invalid CC address: {}", e)))?;
281            email_builder = email_builder.cc(cc_mailbox);
282        }
283
284        // Add BCC recipients
285        for bcc in &message.bcc {
286            let bcc_mailbox: Mailbox = bcc
287                .parse()
288                .map_err(|e| Error::mail(format!("Invalid BCC address: {}", e)))?;
289            email_builder = email_builder.bcc(bcc_mailbox);
290        }
291
292        // Build the message body
293        let email = if let Some(ref html) = message.html {
294            email_builder
295                .header(ContentType::TEXT_HTML)
296                .body(html.clone())
297                .map_err(|e| Error::mail(format!("Failed to build email: {}", e)))?
298        } else {
299            email_builder
300                .header(ContentType::TEXT_PLAIN)
301                .body(message.body.clone())
302                .map_err(|e| Error::mail(format!("Failed to build email: {}", e)))?
303        };
304
305        // Build the transport
306        let transport = if config.tls {
307            AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
308                .map_err(|e| Error::mail(format!("Failed to create transport: {}", e)))?
309        } else {
310            AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host)
311        };
312
313        let transport = transport.port(config.port);
314
315        let transport =
316            if let (Some(ref user), Some(ref pass)) = (&config.username, &config.password) {
317                transport.credentials(Credentials::new(user.clone(), pass.clone()))
318            } else {
319                transport
320            };
321
322        let mailer = transport.build();
323
324        // Send the email
325        mailer
326            .send(email)
327            .await
328            .map_err(|e| Error::mail(format!("Failed to send email: {}", e)))?;
329
330        info!(to = %to, "Mail notification sent");
331        Ok(())
332    }
333
334    /// Send a database notification.
335    async fn send_database<N: Notifiable + ?Sized>(
336        notifiable: &N,
337        message: &crate::channels::DatabaseMessage,
338    ) -> Result<(), Error> {
339        let notifiable_id = notifiable.notifiable_id();
340        let notifiable_type = notifiable.notifiable_type();
341
342        info!(
343            notifiable_id = %notifiable_id,
344            notification_type = %message.notification_type,
345            "Storing database notification"
346        );
347
348        // In a real implementation, this would store to the database.
349        // For now, we just log it. The user should implement DatabaseNotificationStore.
350        info!(
351            notifiable_id = %notifiable_id,
352            notifiable_type = %notifiable_type,
353            notification_type = %message.notification_type,
354            data = ?message.data,
355            "Database notification stored (placeholder)"
356        );
357
358        Ok(())
359    }
360
361    /// Send a Slack notification.
362    async fn send_slack<N: Notifiable + ?Sized>(
363        notifiable: &N,
364        message: &SlackMessage,
365    ) -> Result<(), Error> {
366        let webhook_url = notifiable
367            .route_notification_for(Channel::Slack)
368            .or_else(|| CONFIG.get().and_then(|c| c.slack_webhook.clone()))
369            .ok_or_else(|| Error::ChannelNotAvailable("No Slack webhook configured".into()))?;
370
371        info!(channel = ?message.channel, "Sending Slack notification");
372
373        let client = reqwest::Client::new();
374        let response = client
375            .post(&webhook_url)
376            .json(message)
377            .send()
378            .await
379            .map_err(|e| Error::slack(format!("HTTP request failed: {}", e)))?;
380
381        if !response.status().is_success() {
382            let status = response.status();
383            let body = response.text().await.unwrap_or_default();
384            error!(status = %status, body = %body, "Slack webhook failed");
385            return Err(Error::slack(format!("Slack returned {}: {}", status, body)));
386        }
387
388        info!("Slack notification sent");
389        Ok(())
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_mail_config_builder() {
399        let config = MailConfig::new("smtp.example.com", 587, "noreply@example.com")
400            .credentials("user", "pass")
401            .from_name("My App");
402
403        assert_eq!(config.host, "smtp.example.com");
404        assert_eq!(config.port, 587);
405        assert_eq!(config.from, "noreply@example.com");
406        assert_eq!(config.username, Some("user".to_string()));
407        assert_eq!(config.password, Some("pass".to_string()));
408        assert_eq!(config.from_name, Some("My App".to_string()));
409        assert!(config.tls);
410    }
411
412    #[test]
413    fn test_notification_config_default() {
414        let config = NotificationConfig::default();
415        assert!(config.mail.is_none());
416        assert!(config.slack_webhook.is_none());
417    }
418}