1use crate::channel::Channel;
4use crate::channels::{MailMessage, SlackMessage};
5use crate::notifiable::Notifiable;
6use crate::notification::Notification;
7use crate::Error;
8use serde::Serialize;
9use std::env;
10use std::sync::OnceLock;
11use tracing::{error, info};
12
13static CONFIG: OnceLock<NotificationConfig> = OnceLock::new();
15
16#[derive(Clone, Default)]
18pub struct NotificationConfig {
19 pub mail: Option<MailConfig>,
21 pub slack_webhook: Option<String>,
23}
24
25#[derive(Debug, Clone, Default)]
27pub enum MailDriver {
28 #[default]
30 Smtp,
31 Resend,
33}
34
35#[derive(Clone)]
37pub struct SmtpConfig {
38 pub host: String,
40 pub port: u16,
42 pub username: Option<String>,
44 pub password: Option<String>,
46 pub tls: bool,
48}
49
50#[derive(Clone)]
52pub struct ResendConfig {
53 pub api_key: String,
55}
56
57#[derive(Clone)]
59pub struct MailConfig {
60 pub driver: MailDriver,
62 pub from: String,
64 pub from_name: Option<String>,
66 pub smtp: Option<SmtpConfig>,
68 pub resend: Option<ResendConfig>,
70}
71
72impl NotificationConfig {
73 pub fn new() -> Self {
75 Self::default()
76 }
77
78 pub fn from_env() -> Self {
95 Self {
96 mail: MailConfig::from_env(),
97 slack_webhook: env::var("SLACK_WEBHOOK_URL").ok().filter(|s| !s.is_empty()),
98 }
99 }
100
101 pub fn mail(mut self, config: MailConfig) -> Self {
103 self.mail = Some(config);
104 self
105 }
106
107 pub fn slack_webhook(mut self, url: impl Into<String>) -> Self {
109 self.slack_webhook = Some(url.into());
110 self
111 }
112}
113
114impl MailConfig {
115 pub fn new(host: impl Into<String>, port: u16, from: impl Into<String>) -> Self {
117 Self {
118 driver: MailDriver::Smtp,
119 from: from.into(),
120 from_name: None,
121 smtp: Some(SmtpConfig {
122 host: host.into(),
123 port,
124 username: None,
125 password: None,
126 tls: true,
127 }),
128 resend: None,
129 }
130 }
131
132 pub fn resend(api_key: impl Into<String>, from: impl Into<String>) -> Self {
134 Self {
135 driver: MailDriver::Resend,
136 from: from.into(),
137 from_name: None,
138 smtp: None,
139 resend: Some(ResendConfig {
140 api_key: api_key.into(),
141 }),
142 }
143 }
144
145 pub fn from_env() -> Option<Self> {
174 let from = env::var("MAIL_FROM_ADDRESS")
175 .ok()
176 .filter(|s| !s.is_empty())?;
177 let from_name = env::var("MAIL_FROM_NAME").ok().filter(|s| !s.is_empty());
178
179 let driver_str = env::var("MAIL_DRIVER")
180 .ok()
181 .filter(|s| !s.is_empty())
182 .unwrap_or_else(|| "smtp".into());
183
184 match driver_str.to_lowercase().as_str() {
185 "resend" => {
186 let api_key = env::var("RESEND_API_KEY").ok().filter(|s| !s.is_empty())?;
187
188 Some(Self {
189 driver: MailDriver::Resend,
190 from,
191 from_name,
192 smtp: None,
193 resend: Some(ResendConfig { api_key }),
194 })
195 }
196 _ => {
197 let host = env::var("MAIL_HOST").ok().filter(|s| !s.is_empty())?;
199
200 let port = env::var("MAIL_PORT")
201 .ok()
202 .and_then(|p| p.parse().ok())
203 .unwrap_or(587);
204
205 let username = env::var("MAIL_USERNAME").ok().filter(|s| !s.is_empty());
206 let password = env::var("MAIL_PASSWORD").ok().filter(|s| !s.is_empty());
207
208 let tls = env::var("MAIL_ENCRYPTION")
209 .map(|v| v.to_lowercase() != "none")
210 .unwrap_or(true);
211
212 Some(Self {
213 driver: MailDriver::Smtp,
214 from,
215 from_name,
216 smtp: Some(SmtpConfig {
217 host,
218 port,
219 username,
220 password,
221 tls,
222 }),
223 resend: None,
224 })
225 }
226 }
227 }
228
229 pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
231 let smtp = self.smtp.get_or_insert(SmtpConfig {
232 host: String::new(),
233 port: 587,
234 username: None,
235 password: None,
236 tls: true,
237 });
238 smtp.username = Some(username.into());
239 smtp.password = Some(password.into());
240 self
241 }
242
243 pub fn from_name(mut self, name: impl Into<String>) -> Self {
245 self.from_name = Some(name.into());
246 self
247 }
248
249 pub fn no_tls(mut self) -> Self {
251 if let Some(ref mut smtp) = self.smtp {
252 smtp.tls = false;
253 }
254 self
255 }
256}
257
258#[derive(Serialize)]
260struct ResendEmailPayload {
261 from: String,
262 to: Vec<String>,
263 subject: String,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 html: Option<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 text: Option<String>,
268 #[serde(skip_serializing_if = "Vec::is_empty")]
269 cc: Vec<String>,
270 #[serde(skip_serializing_if = "Vec::is_empty")]
271 bcc: Vec<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 reply_to: Option<String>,
274}
275
276pub struct NotificationDispatcher;
278
279impl NotificationDispatcher {
280 pub fn configure(config: NotificationConfig) {
282 let _ = CONFIG.set(config);
283 }
284
285 pub fn config() -> Option<&'static NotificationConfig> {
287 CONFIG.get()
288 }
289
290 pub async fn send<N, T>(notifiable: &N, notification: T) -> Result<(), Error>
292 where
293 N: Notifiable + ?Sized,
294 T: Notification,
295 {
296 let channels = notification.via();
297 let notification_type = notification.notification_type();
298
299 info!(
300 notification = notification_type,
301 channels = ?channels,
302 "Dispatching notification"
303 );
304
305 for channel in channels {
306 match channel {
307 Channel::Mail => {
308 if let Some(mail) = notification.to_mail() {
309 Self::send_mail(notifiable, &mail).await?;
310 }
311 }
312 Channel::Database => {
313 if let Some(db_msg) = notification.to_database() {
314 Self::send_database(notifiable, &db_msg).await?;
315 }
316 }
317 Channel::Slack => {
318 if let Some(slack) = notification.to_slack() {
319 Self::send_slack(notifiable, &slack).await?;
320 }
321 }
322 Channel::Sms | Channel::Push => {
323 info!(channel = %channel, "Channel not implemented");
325 }
326 }
327 }
328
329 Ok(())
330 }
331
332 async fn send_mail<N: Notifiable + ?Sized>(
334 notifiable: &N,
335 message: &MailMessage,
336 ) -> Result<(), Error> {
337 let to = notifiable
338 .route_notification_for(Channel::Mail)
339 .ok_or_else(|| Error::ChannelNotAvailable("No mail route configured".into()))?;
340
341 let config = CONFIG
342 .get()
343 .and_then(|c| c.mail.as_ref())
344 .ok_or_else(|| Error::ChannelNotAvailable("Mail not configured".into()))?;
345
346 info!(to = %to, subject = %message.subject, "Sending mail notification");
347
348 match config.driver {
349 MailDriver::Smtp => Self::send_mail_smtp(&to, message, config).await,
350 MailDriver::Resend => Self::send_mail_resend(&to, message, config).await,
351 }
352 }
353
354 async fn send_mail_smtp(
356 to: &str,
357 message: &MailMessage,
358 config: &MailConfig,
359 ) -> Result<(), Error> {
360 let smtp = config
361 .smtp
362 .as_ref()
363 .ok_or_else(|| Error::mail("SMTP config missing for SMTP driver"))?;
364
365 use lettre::message::{header::ContentType, Mailbox};
366 use lettre::transport::smtp::authentication::Credentials;
367 use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
368
369 let from: Mailbox = if let Some(ref name) = config.from_name {
370 format!("{} <{}>", name, config.from)
371 .parse()
372 .map_err(|e| Error::mail(format!("Invalid from address: {e}")))?
373 } else {
374 config
375 .from
376 .parse()
377 .map_err(|e| Error::mail(format!("Invalid from address: {e}")))?
378 };
379
380 let to_mailbox: Mailbox = to
381 .parse()
382 .map_err(|e| Error::mail(format!("Invalid to address: {e}")))?;
383
384 let mut email_builder = Message::builder()
385 .from(from)
386 .to(to_mailbox)
387 .subject(&message.subject);
388
389 if let Some(ref reply_to) = message.reply_to {
390 let reply_to_mailbox: Mailbox = reply_to
391 .parse()
392 .map_err(|e| Error::mail(format!("Invalid reply-to address: {e}")))?;
393 email_builder = email_builder.reply_to(reply_to_mailbox);
394 }
395
396 for cc in &message.cc {
397 let cc_mailbox: Mailbox = cc
398 .parse()
399 .map_err(|e| Error::mail(format!("Invalid CC address: {e}")))?;
400 email_builder = email_builder.cc(cc_mailbox);
401 }
402
403 for bcc in &message.bcc {
404 let bcc_mailbox: Mailbox = bcc
405 .parse()
406 .map_err(|e| Error::mail(format!("Invalid BCC address: {e}")))?;
407 email_builder = email_builder.bcc(bcc_mailbox);
408 }
409
410 let email = if let Some(ref html) = message.html {
411 email_builder
412 .header(ContentType::TEXT_HTML)
413 .body(html.clone())
414 .map_err(|e| Error::mail(format!("Failed to build email: {e}")))?
415 } else {
416 email_builder
417 .header(ContentType::TEXT_PLAIN)
418 .body(message.body.clone())
419 .map_err(|e| Error::mail(format!("Failed to build email: {e}")))?
420 };
421
422 let transport = if smtp.tls {
423 AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
424 .map_err(|e| Error::mail(format!("Failed to create transport: {e}")))?
425 } else {
426 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
427 };
428
429 let transport = transport.port(smtp.port);
430
431 let transport = if let (Some(ref user), Some(ref pass)) = (&smtp.username, &smtp.password) {
432 transport.credentials(Credentials::new(user.clone(), pass.clone()))
433 } else {
434 transport
435 };
436
437 let mailer = transport.build();
438
439 mailer
440 .send(email)
441 .await
442 .map_err(|e| Error::mail(format!("Failed to send email: {e}")))?;
443
444 info!(to = %to, "Mail notification sent via SMTP");
445 Ok(())
446 }
447
448 async fn send_mail_resend(
450 to: &str,
451 message: &MailMessage,
452 config: &MailConfig,
453 ) -> Result<(), Error> {
454 let resend = config
455 .resend
456 .as_ref()
457 .ok_or_else(|| Error::mail("Resend config missing for Resend driver"))?;
458
459 let from = message.from.clone().unwrap_or_else(|| {
460 if let Some(ref name) = config.from_name {
461 format!("{} <{}>", name, config.from)
462 } else {
463 config.from.clone()
464 }
465 });
466
467 let payload = ResendEmailPayload {
468 from,
469 to: vec![to.to_string()],
470 subject: message.subject.clone(),
471 html: message.html.clone(),
472 text: if message.html.is_some() {
473 None
474 } else {
475 Some(message.body.clone())
476 },
477 cc: message.cc.clone(),
478 bcc: message.bcc.clone(),
479 reply_to: message.reply_to.clone(),
480 };
481
482 let client = reqwest::Client::new();
483 let response = client
484 .post("https://api.resend.com/emails")
485 .bearer_auth(&resend.api_key)
486 .json(&payload)
487 .send()
488 .await
489 .map_err(|e| Error::mail(format!("Resend HTTP request failed: {e}")))?;
490
491 if !response.status().is_success() {
492 let status = response.status();
493 let body = response.text().await.unwrap_or_default();
494 error!(status = %status, body = %body, "Resend API error");
495 return Err(Error::mail(format!("Resend API error {status}: {body}")));
496 }
497
498 info!(to = %to, "Mail notification sent via Resend");
499 Ok(())
500 }
501
502 async fn send_database<N: Notifiable + ?Sized>(
504 notifiable: &N,
505 message: &crate::channels::DatabaseMessage,
506 ) -> Result<(), Error> {
507 let notifiable_id = notifiable.notifiable_id();
508 let notifiable_type = notifiable.notifiable_type();
509
510 info!(
511 notifiable_id = %notifiable_id,
512 notification_type = %message.notification_type,
513 "Storing database notification"
514 );
515
516 info!(
519 notifiable_id = %notifiable_id,
520 notifiable_type = %notifiable_type,
521 notification_type = %message.notification_type,
522 data = ?message.data,
523 "Database notification stored (placeholder)"
524 );
525
526 Ok(())
527 }
528
529 async fn send_slack<N: Notifiable + ?Sized>(
531 notifiable: &N,
532 message: &SlackMessage,
533 ) -> Result<(), Error> {
534 let webhook_url = notifiable
535 .route_notification_for(Channel::Slack)
536 .or_else(|| CONFIG.get().and_then(|c| c.slack_webhook.clone()))
537 .ok_or_else(|| Error::ChannelNotAvailable("No Slack webhook configured".into()))?;
538
539 info!(channel = ?message.channel, "Sending Slack notification");
540
541 let client = reqwest::Client::new();
542 let response = client
543 .post(&webhook_url)
544 .json(message)
545 .send()
546 .await
547 .map_err(|e| Error::slack(format!("HTTP request failed: {e}")))?;
548
549 if !response.status().is_success() {
550 let status = response.status();
551 let body = response.text().await.unwrap_or_default();
552 error!(status = %status, body = %body, "Slack webhook failed");
553 return Err(Error::slack(format!("Slack returned {status}: {body}")));
554 }
555
556 info!("Slack notification sent");
557 Ok(())
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use serial_test::serial;
565
566 #[test]
567 fn test_mail_config_smtp_builder() {
568 let config = MailConfig::new("smtp.example.com", 587, "noreply@example.com")
569 .credentials("user", "pass")
570 .from_name("My App");
571
572 assert!(matches!(config.driver, MailDriver::Smtp));
573 assert_eq!(config.from, "noreply@example.com");
574 assert_eq!(config.from_name, Some("My App".to_string()));
575
576 let smtp = config.smtp.as_ref().unwrap();
577 assert_eq!(smtp.host, "smtp.example.com");
578 assert_eq!(smtp.port, 587);
579 assert_eq!(smtp.username, Some("user".to_string()));
580 assert_eq!(smtp.password, Some("pass".to_string()));
581 assert!(smtp.tls);
582 assert!(config.resend.is_none());
583 }
584
585 #[test]
586 fn test_mail_config_resend_builder() {
587 let config = MailConfig::resend("re_123456", "noreply@example.com").from_name("My App");
588
589 assert!(matches!(config.driver, MailDriver::Resend));
590 assert_eq!(config.from, "noreply@example.com");
591 assert_eq!(config.from_name, Some("My App".to_string()));
592
593 let resend = config.resend.as_ref().unwrap();
594 assert_eq!(resend.api_key, "re_123456");
595 assert!(config.smtp.is_none());
596 }
597
598 #[test]
599 fn test_mail_config_no_tls() {
600 let config = MailConfig::new("smtp.example.com", 587, "noreply@example.com").no_tls();
601
602 let smtp = config.smtp.as_ref().unwrap();
603 assert!(!smtp.tls);
604 }
605
606 #[test]
607 fn test_notification_config_default() {
608 let config = NotificationConfig::default();
609 assert!(config.mail.is_none());
610 assert!(config.slack_webhook.is_none());
611 }
612
613 fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
615 for (key, val) in vars {
617 unsafe { env::set_var(key, val) };
618 }
619 f();
620 for (key, _) in vars {
622 unsafe { env::remove_var(key) };
623 }
624 }
625
626 fn clean_mail_env() {
628 let keys = [
629 "MAIL_DRIVER",
630 "MAIL_FROM_ADDRESS",
631 "MAIL_FROM_NAME",
632 "MAIL_HOST",
633 "MAIL_PORT",
634 "MAIL_USERNAME",
635 "MAIL_PASSWORD",
636 "MAIL_ENCRYPTION",
637 "RESEND_API_KEY",
638 ];
639 for key in keys {
640 unsafe { env::remove_var(key) };
641 }
642 }
643
644 #[test]
645 #[serial]
646 fn test_mail_config_smtp_from_env() {
647 clean_mail_env();
648 with_env_vars(
649 &[
650 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
651 ("MAIL_FROM_NAME", "Test App"),
652 ("MAIL_HOST", "smtp.example.com"),
653 ("MAIL_PORT", "465"),
654 ("MAIL_USERNAME", "user@example.com"),
655 ("MAIL_PASSWORD", "secret"),
656 ("MAIL_ENCRYPTION", "tls"),
657 ],
658 || {
659 let config = MailConfig::from_env().expect("should parse SMTP config");
660 assert!(matches!(config.driver, MailDriver::Smtp));
661 assert_eq!(config.from, "noreply@example.com");
662 assert_eq!(config.from_name, Some("Test App".to_string()));
663
664 let smtp = config.smtp.as_ref().expect("smtp config present");
665 assert_eq!(smtp.host, "smtp.example.com");
666 assert_eq!(smtp.port, 465);
667 assert_eq!(smtp.username, Some("user@example.com".to_string()));
668 assert_eq!(smtp.password, Some("secret".to_string()));
669 assert!(smtp.tls);
670 assert!(config.resend.is_none());
671 },
672 );
673 }
674
675 #[test]
676 #[serial]
677 fn test_mail_config_resend_from_env() {
678 clean_mail_env();
679 with_env_vars(
680 &[
681 ("MAIL_DRIVER", "resend"),
682 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
683 ("MAIL_FROM_NAME", "Test App"),
684 ("RESEND_API_KEY", "re_test_123456"),
685 ],
686 || {
687 let config = MailConfig::from_env().expect("should parse Resend config");
688 assert!(matches!(config.driver, MailDriver::Resend));
689 assert_eq!(config.from, "noreply@example.com");
690 assert_eq!(config.from_name, Some("Test App".to_string()));
691
692 let resend = config.resend.as_ref().expect("resend config present");
693 assert_eq!(resend.api_key, "re_test_123456");
694 assert!(config.smtp.is_none());
695 },
696 );
697 }
698
699 #[test]
700 #[serial]
701 fn test_mail_config_default_driver() {
702 clean_mail_env();
703 with_env_vars(
704 &[
705 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
706 ("MAIL_HOST", "smtp.example.com"),
707 ],
708 || {
709 let config = MailConfig::from_env().expect("should default to SMTP");
710 assert!(matches!(config.driver, MailDriver::Smtp));
711 assert_eq!(config.smtp.as_ref().unwrap().host, "smtp.example.com");
712 assert_eq!(config.smtp.as_ref().unwrap().port, 587); },
714 );
715 }
716
717 #[test]
718 #[serial]
719 fn test_mail_config_resend_missing_api_key() {
720 clean_mail_env();
721 with_env_vars(
722 &[
723 ("MAIL_DRIVER", "resend"),
724 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
725 ],
726 || {
727 let config = MailConfig::from_env();
728 assert!(
729 config.is_none(),
730 "should return None when RESEND_API_KEY missing"
731 );
732 },
733 );
734 }
735
736 #[test]
737 fn test_resend_payload_serialization() {
738 let payload = ResendEmailPayload {
739 from: "sender@example.com".into(),
740 to: vec!["recipient@example.com".into()],
741 subject: "Test".into(),
742 html: Some("<p>Hello</p>".into()),
743 text: None,
744 cc: vec![],
745 bcc: vec![],
746 reply_to: None,
747 };
748
749 let json = serde_json::to_value(&payload).unwrap();
750 assert_eq!(json["from"], "sender@example.com");
751 assert_eq!(json["to"][0], "recipient@example.com");
752 assert_eq!(json["subject"], "Test");
753 assert_eq!(json["html"], "<p>Hello</p>");
754 assert!(json.get("text").is_none());
756 assert!(json.get("cc").is_none());
757 assert!(json.get("bcc").is_none());
758 assert!(json.get("reply_to").is_none());
759 }
760
761 #[test]
762 fn test_resend_payload_text_fallback() {
763 let payload = ResendEmailPayload {
764 from: "sender@example.com".into(),
765 to: vec!["recipient@example.com".into()],
766 subject: "Test".into(),
767 html: None,
768 text: Some("Plain text body".into()),
769 cc: vec!["cc@example.com".into()],
770 bcc: vec!["bcc@example.com".into()],
771 reply_to: Some("reply@example.com".into()),
772 };
773
774 let json = serde_json::to_value(&payload).unwrap();
775 assert!(json.get("html").is_none());
776 assert_eq!(json["text"], "Plain text body");
777 assert_eq!(json["cc"][0], "cc@example.com");
778 assert_eq!(json["bcc"][0], "bcc@example.com");
779 assert_eq!(json["reply_to"], "reply@example.com");
780 }
781}