1use 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
12static CONFIG: OnceLock<NotificationConfig> = OnceLock::new();
14
15#[derive(Clone, Default)]
17pub struct NotificationConfig {
18 pub mail: Option<MailConfig>,
20 pub slack_webhook: Option<String>,
22}
23
24#[derive(Clone)]
26pub struct MailConfig {
27 pub host: String,
29 pub port: u16,
31 pub username: Option<String>,
33 pub password: Option<String>,
35 pub from: String,
37 pub from_name: Option<String>,
39 pub tls: bool,
41}
42
43impl NotificationConfig {
44 pub fn new() -> Self {
46 Self::default()
47 }
48
49 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 pub fn mail(mut self, config: MailConfig) -> Self {
74 self.mail = Some(config);
75 self
76 }
77
78 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 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 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 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 pub fn from_name(mut self, name: impl Into<String>) -> Self {
160 self.from_name = Some(name.into());
161 self
162 }
163
164 pub fn no_tls(mut self) -> Self {
166 self.tls = false;
167 self
168 }
169}
170
171pub struct NotificationDispatcher;
173
174impl NotificationDispatcher {
175 pub fn configure(config: NotificationConfig) {
177 let _ = CONFIG.set(config);
178 }
179
180 pub fn config() -> Option<&'static NotificationConfig> {
182 CONFIG.get()
183 }
184
185 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 info!(channel = %channel, "Channel not implemented");
220 }
221 }
222 }
223
224 Ok(())
225 }
226
227 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 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 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 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 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 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 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 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 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 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 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}