1use crate::channel::Channel;
4use crate::channels::{DatabaseMessage, InAppMessage, MailMessage, SlackMessage, WhatsAppMessage};
5use crate::notifiable::{DatabaseNotificationStore, Notifiable};
6use crate::notification::Notification;
7use crate::Error;
8use serde::Serialize;
9use std::env;
10use std::sync::{Arc, OnceLock};
11use tracing::{error, info, warn};
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 pub whatsapp_enabled: bool,
29 pub in_app: Option<InAppConfig>,
34 pub database_store: Option<Arc<dyn DatabaseNotificationStore>>,
41}
42
43#[derive(Clone)]
49pub struct InAppConfig {
50 pub broker: Arc<ferro_broadcast::Broadcaster>,
52 pub store: Arc<dyn DatabaseNotificationStore>,
54}
55
56#[derive(Debug, Clone, Default)]
58pub enum MailDriver {
59 #[default]
61 Smtp,
62 Resend,
64}
65
66#[derive(Clone)]
68pub struct SmtpConfig {
69 pub host: String,
71 pub port: u16,
73 pub username: Option<String>,
75 pub password: Option<String>,
77 pub tls: bool,
79}
80
81#[derive(Clone)]
83pub struct ResendConfig {
84 pub api_key: String,
86}
87
88#[derive(Clone)]
90pub struct MailConfig {
91 pub driver: MailDriver,
93 pub from: String,
95 pub from_name: Option<String>,
97 pub smtp: Option<SmtpConfig>,
99 pub resend: Option<ResendConfig>,
101}
102
103impl NotificationConfig {
104 pub fn new() -> Self {
106 Self::default()
107 }
108
109 pub fn from_env() -> Self {
126 Self {
127 mail: MailConfig::from_env(),
128 slack_webhook: env::var("SLACK_WEBHOOK_URL").ok().filter(|s| !s.is_empty()),
129 whatsapp_enabled: env::var("WHATSAPP_ENABLED")
130 .ok()
131 .and_then(|v| v.parse::<bool>().ok())
132 .unwrap_or(false),
133 in_app: None,
136 database_store: None,
137 }
138 }
139
140 pub fn mail(mut self, config: MailConfig) -> Self {
142 self.mail = Some(config);
143 self
144 }
145
146 pub fn slack_webhook(mut self, url: impl Into<String>) -> Self {
148 self.slack_webhook = Some(url.into());
149 self
150 }
151
152 pub fn with_whatsapp_enabled(mut self, enabled: bool) -> Self {
154 self.whatsapp_enabled = enabled;
155 self
156 }
157
158 pub fn with_in_app(mut self, config: InAppConfig) -> Self {
160 self.in_app = Some(config);
161 self
162 }
163
164 pub fn with_database_store(mut self, store: Arc<dyn DatabaseNotificationStore>) -> Self {
169 self.database_store = Some(store);
170 self
171 }
172}
173
174impl MailConfig {
175 pub fn new(host: impl Into<String>, port: u16, from: impl Into<String>) -> Self {
177 Self {
178 driver: MailDriver::Smtp,
179 from: from.into(),
180 from_name: None,
181 smtp: Some(SmtpConfig {
182 host: host.into(),
183 port,
184 username: None,
185 password: None,
186 tls: true,
187 }),
188 resend: None,
189 }
190 }
191
192 pub fn resend(api_key: impl Into<String>, from: impl Into<String>) -> Self {
194 Self {
195 driver: MailDriver::Resend,
196 from: from.into(),
197 from_name: None,
198 smtp: None,
199 resend: Some(ResendConfig {
200 api_key: api_key.into(),
201 }),
202 }
203 }
204
205 pub fn from_env() -> Option<Self> {
234 let from = env::var("MAIL_FROM_ADDRESS")
235 .ok()
236 .filter(|s| !s.is_empty())?;
237 let from_name = env::var("MAIL_FROM_NAME").ok().filter(|s| !s.is_empty());
238
239 let driver_str = env::var("MAIL_DRIVER")
240 .ok()
241 .filter(|s| !s.is_empty())
242 .unwrap_or_else(|| "smtp".into());
243
244 match driver_str.to_lowercase().as_str() {
245 "resend" => {
246 let api_key = env::var("RESEND_API_KEY").ok().filter(|s| !s.is_empty())?;
247
248 Some(Self {
249 driver: MailDriver::Resend,
250 from,
251 from_name,
252 smtp: None,
253 resend: Some(ResendConfig { api_key }),
254 })
255 }
256 _ => {
257 let host = env::var("MAIL_HOST").ok().filter(|s| !s.is_empty())?;
259
260 let port = env::var("MAIL_PORT")
261 .ok()
262 .and_then(|p| p.parse().ok())
263 .unwrap_or(587);
264
265 let username = env::var("MAIL_USERNAME").ok().filter(|s| !s.is_empty());
266 let password = env::var("MAIL_PASSWORD").ok().filter(|s| !s.is_empty());
267
268 let tls = env::var("MAIL_ENCRYPTION")
269 .map(|v| v.to_lowercase() != "none")
270 .unwrap_or(true);
271
272 Some(Self {
273 driver: MailDriver::Smtp,
274 from,
275 from_name,
276 smtp: Some(SmtpConfig {
277 host,
278 port,
279 username,
280 password,
281 tls,
282 }),
283 resend: None,
284 })
285 }
286 }
287 }
288
289 pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
295 if !matches!(self.driver, MailDriver::Smtp) {
296 warn!("MailConfig::credentials called on non-SMTP driver; ignoring");
297 return self;
298 }
299 let smtp = self.smtp.get_or_insert_with(|| SmtpConfig {
300 host: String::new(),
301 port: 587,
302 username: None,
303 password: None,
304 tls: true,
305 });
306 smtp.username = Some(username.into());
307 smtp.password = Some(password.into());
308 self
309 }
310
311 pub fn from_name(mut self, name: impl Into<String>) -> Self {
313 self.from_name = Some(name.into());
314 self
315 }
316
317 pub fn no_tls(mut self) -> Self {
319 if let Some(ref mut smtp) = self.smtp {
320 smtp.tls = false;
321 }
322 self
323 }
324}
325
326#[derive(Serialize)]
328struct ResendAttachment {
329 filename: String,
330 content: String,
332}
333
334#[derive(Serialize)]
336struct ResendEmailPayload {
337 from: String,
338 to: Vec<String>,
339 subject: String,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 html: Option<String>,
342 #[serde(skip_serializing_if = "Option::is_none")]
343 text: Option<String>,
344 #[serde(skip_serializing_if = "Vec::is_empty")]
345 cc: Vec<String>,
346 #[serde(skip_serializing_if = "Vec::is_empty")]
347 bcc: Vec<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
349 reply_to: Option<String>,
350 #[serde(skip_serializing_if = "Vec::is_empty")]
351 attachments: Vec<ResendAttachment>,
352}
353
354pub struct NotificationDispatcher;
356
357impl NotificationDispatcher {
358 pub fn configure(config: NotificationConfig) {
360 let _ = CONFIG.set(config);
361 }
362
363 pub fn config() -> Option<&'static NotificationConfig> {
365 CONFIG.get()
366 }
367
368 pub async fn send<N, T>(notifiable: &N, notification: T) -> Result<(), Error>
370 where
371 N: Notifiable + ?Sized,
372 T: Notification,
373 {
374 let channels = notification.via();
375 let notification_type = notification.notification_type();
376
377 info!(
378 notification = notification_type,
379 channels = ?channels,
380 "Dispatching notification"
381 );
382
383 for channel in channels {
384 match channel {
385 Channel::Mail => {
386 if let Some(mail) = notification.to_mail() {
387 Self::send_mail(notifiable, &mail).await?;
388 }
389 }
390 Channel::Database => {
391 if let Some(db_msg) = notification.to_database() {
392 Self::send_database(notifiable, &db_msg).await?;
393 }
394 }
395 Channel::Slack => {
396 if let Some(slack) = notification.to_slack() {
397 Self::send_slack(notifiable, &slack).await?;
398 }
399 }
400 Channel::WhatsApp => {
401 if let Some(wa) = notification.to_whatsapp() {
402 Self::send_whatsapp(notifiable, &wa).await?;
403 }
404 }
405 Channel::InApp => {
406 if let Some(in_app) = notification.to_in_app() {
407 Self::send_in_app(notifiable, &in_app).await?;
408 }
409 }
410 Channel::Sms | Channel::Push => {
411 info!(channel = %channel, "Channel not implemented");
413 }
414 }
415 }
416
417 Ok(())
418 }
419
420 async fn send_mail<N: Notifiable + ?Sized>(
422 notifiable: &N,
423 message: &MailMessage,
424 ) -> Result<(), Error> {
425 let to = notifiable
426 .route_notification_for(Channel::Mail)
427 .ok_or_else(|| Error::ChannelNotAvailable("No mail route configured".into()))?;
428
429 let config = CONFIG
430 .get()
431 .and_then(|c| c.mail.as_ref())
432 .ok_or_else(|| Error::ChannelNotAvailable("Mail not configured".into()))?;
433
434 info!(to = %to, subject = %message.subject, "Sending mail notification");
435
436 match config.driver {
437 MailDriver::Smtp => Self::send_mail_smtp(&to, message, config).await,
438 MailDriver::Resend => Self::send_mail_resend(&to, message, config).await,
439 }
440 }
441
442 async fn send_mail_smtp(
444 to: &str,
445 message: &MailMessage,
446 config: &MailConfig,
447 ) -> Result<(), Error> {
448 let smtp = config
449 .smtp
450 .as_ref()
451 .ok_or_else(|| Error::mail("SMTP config missing for SMTP driver"))?;
452
453 use lettre::message::{header::ContentType, Attachment, Mailbox, MultiPart, SinglePart};
454 use lettre::transport::smtp::authentication::Credentials;
455 use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
456
457 let from: Mailbox = if let Some(ref name) = config.from_name {
458 format!("{} <{}>", name, config.from)
459 .parse()
460 .map_err(|e| Error::mail(format!("Invalid from address: {e}")))?
461 } else {
462 config
463 .from
464 .parse()
465 .map_err(|e| Error::mail(format!("Invalid from address: {e}")))?
466 };
467
468 let to_mailbox: Mailbox = to
469 .parse()
470 .map_err(|e| Error::mail(format!("Invalid to address: {e}")))?;
471
472 let mut email_builder = Message::builder()
473 .from(from)
474 .to(to_mailbox)
475 .subject(&message.subject);
476
477 if let Some(ref reply_to) = message.reply_to {
478 let reply_to_mailbox: Mailbox = reply_to
479 .parse()
480 .map_err(|e| Error::mail(format!("Invalid reply-to address: {e}")))?;
481 email_builder = email_builder.reply_to(reply_to_mailbox);
482 }
483
484 for cc in &message.cc {
485 let cc_mailbox: Mailbox = cc
486 .parse()
487 .map_err(|e| Error::mail(format!("Invalid CC address: {e}")))?;
488 email_builder = email_builder.cc(cc_mailbox);
489 }
490
491 for bcc in &message.bcc {
492 let bcc_mailbox: Mailbox = bcc
493 .parse()
494 .map_err(|e| Error::mail(format!("Invalid BCC address: {e}")))?;
495 email_builder = email_builder.bcc(bcc_mailbox);
496 }
497
498 let email = if message.attachments.is_empty() {
501 if let Some(ref html) = message.html {
503 email_builder
504 .header(ContentType::TEXT_HTML)
505 .body(html.clone())
506 .map_err(|e| Error::mail(format!("Failed to build email: {e}")))?
507 } else {
508 email_builder
509 .header(ContentType::TEXT_PLAIN)
510 .body(message.body.clone())
511 .map_err(|e| Error::mail(format!("Failed to build email: {e}")))?
512 }
513 } else {
514 let body_part = if let Some(ref html) = message.html {
516 SinglePart::html(html.clone())
517 } else {
518 SinglePart::plain(message.body.clone())
519 };
520
521 let mut mp = MultiPart::mixed().singlepart(body_part);
522 for att in &message.attachments {
523 let ct = ContentType::parse(&att.content_type).map_err(|e| {
524 Error::mail(format!("Invalid content-type '{}': {e}", att.content_type))
525 })?;
526 let part = Attachment::new(att.filename.clone()).body(att.content.clone(), ct);
527 mp = mp.singlepart(part);
528 }
529
530 email_builder
531 .multipart(mp)
532 .map_err(|e| Error::mail(format!("Failed to build multipart email: {e}")))?
533 };
534
535 let transport = if smtp.tls {
536 AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
537 .map_err(|e| Error::mail(format!("Failed to create transport: {e}")))?
538 } else {
539 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
540 };
541
542 let transport = transport.port(smtp.port);
543
544 let transport = if let (Some(ref user), Some(ref pass)) = (&smtp.username, &smtp.password) {
545 transport.credentials(Credentials::new(user.clone(), pass.clone()))
546 } else {
547 transport
548 };
549
550 let mailer = transport.build();
551
552 mailer
553 .send(email)
554 .await
555 .map_err(|e| Error::mail(format!("Failed to send email: {e}")))?;
556
557 info!(to = %to, "Mail notification sent via SMTP");
558 Ok(())
559 }
560
561 async fn send_mail_resend(
563 to: &str,
564 message: &MailMessage,
565 config: &MailConfig,
566 ) -> Result<(), Error> {
567 let resend = config
568 .resend
569 .as_ref()
570 .ok_or_else(|| Error::mail("Resend config missing for Resend driver"))?;
571
572 let from = message.from.clone().unwrap_or_else(|| {
573 if let Some(ref name) = config.from_name {
574 format!("{} <{}>", name, config.from)
575 } else {
576 config.from.clone()
577 }
578 });
579
580 use base64::Engine;
581
582 let attachments: Vec<ResendAttachment> = message
583 .attachments
584 .iter()
585 .map(|att| ResendAttachment {
586 filename: att.filename.clone(),
587 content: base64::engine::general_purpose::STANDARD.encode(&att.content),
588 })
589 .collect();
590
591 let payload = ResendEmailPayload {
592 from,
593 to: vec![to.to_string()],
594 subject: message.subject.clone(),
595 html: message.html.clone(),
596 text: if message.html.is_some() {
597 None
598 } else {
599 Some(message.body.clone())
600 },
601 cc: message.cc.clone(),
602 bcc: message.bcc.clone(),
603 reply_to: message.reply_to.clone(),
604 attachments,
605 };
606
607 let client = reqwest::Client::new();
608 let response = client
609 .post("https://api.resend.com/emails")
610 .bearer_auth(&resend.api_key)
611 .json(&payload)
612 .send()
613 .await
614 .map_err(|e| Error::mail(format!("Resend HTTP request failed: {e}")))?;
615
616 if !response.status().is_success() {
617 let status = response.status();
618 let body = response.text().await.unwrap_or_default();
619 error!(status = %status, body = %body, "Resend API error");
620 return Err(Error::mail(format!("Resend API error {status}: {body}")));
621 }
622
623 let resend_id = match response.json::<serde_json::Value>().await {
627 Ok(body) => body
628 .get("id")
629 .and_then(|v| v.as_str())
630 .map(str::to_owned)
631 .unwrap_or_else(|| "<no-id>".to_string()),
632 Err(e) => {
633 warn!(error = %e, "Resend response parse failed; continuing without id");
634 "<unparseable>".to_string()
635 }
636 };
637
638 info!(to = %to, resend_id = %resend_id, "Mail notification sent via Resend");
639 Ok(())
640 }
641
642 async fn send_database<N: Notifiable + ?Sized>(
649 notifiable: &N,
650 message: &DatabaseMessage,
651 ) -> Result<(), Error> {
652 let notifiable_id = notifiable.notifiable_id();
653 let notifiable_type = notifiable.notifiable_type();
654
655 if let Some(store) = CONFIG.get().and_then(|c| c.database_store.as_ref()) {
656 store
657 .store(
658 ¬ifiable_id,
659 notifiable_type,
660 &message.notification_type,
661 message,
662 )
663 .await?;
664 info!(
665 notifiable_id = %notifiable_id,
666 notification_type = %message.notification_type,
667 "Database notification stored"
668 );
669 } else {
670 warn!(
671 notifiable_id = %notifiable_id,
672 notifiable_type = %notifiable_type,
673 notification_type = %message.notification_type,
674 data = ?message.data,
675 "Database notification dropped — no store configured. \
676 Call NotificationConfig::with_database_store() at startup."
677 );
678 }
679
680 Ok(())
681 }
682
683 async fn send_slack<N: Notifiable + ?Sized>(
685 notifiable: &N,
686 message: &SlackMessage,
687 ) -> Result<(), Error> {
688 let webhook_url = notifiable
689 .route_notification_for(Channel::Slack)
690 .or_else(|| CONFIG.get().and_then(|c| c.slack_webhook.clone()))
691 .ok_or_else(|| Error::ChannelNotAvailable("No Slack webhook configured".into()))?;
692
693 info!(channel = ?message.channel, "Sending Slack notification");
694
695 let client = reqwest::Client::new();
696 let response = client
697 .post(&webhook_url)
698 .json(message)
699 .send()
700 .await
701 .map_err(|e| Error::slack(format!("HTTP request failed: {e}")))?;
702
703 if !response.status().is_success() {
704 let status = response.status();
705 let body = response.text().await.unwrap_or_default();
706 error!(status = %status, body = %body, "Slack webhook failed");
707 return Err(Error::slack(format!("Slack returned {status}: {body}")));
708 }
709
710 info!("Slack notification sent");
711 Ok(())
712 }
713
714 async fn send_whatsapp<N: Notifiable + ?Sized>(
727 notifiable: &N,
728 message: &WhatsAppMessage,
729 ) -> Result<(), Error> {
730 let enabled = CONFIG.get().map(|c| c.whatsapp_enabled).unwrap_or(false);
731
732 if !enabled {
733 info!("WhatsApp channel not configured (WHATSAPP_ENABLED=false)");
734 return Ok(());
735 }
736
737 let phone = notifiable
738 .route_notification_for(Channel::WhatsApp)
739 .ok_or_else(|| Error::ChannelNotAvailable("No WhatsApp route configured".into()))?;
740
741 info!(to = %phone, "Sending WhatsApp notification");
742
743 let result = ferro_whatsapp::WhatsApp::send(&phone, message.message.clone()).await?;
745 info!(to = %phone, wamid = %result.wamid, "WhatsApp notification sent");
746 Ok(())
747 }
748
749 async fn send_in_app<N: Notifiable + ?Sized>(
764 notifiable: &N,
765 message: &InAppMessage,
766 ) -> Result<(), Error> {
767 let cfg = match CONFIG.get().and_then(|c| c.in_app.as_ref()) {
768 Some(c) => c,
769 None => {
770 info!("InApp channel not configured");
771 return Ok(());
772 }
773 };
774
775 let notifiable_id = notifiable.notifiable_id();
776 let notifiable_type = notifiable.notifiable_type();
777
778 let db_msg = inapp_to_database_message(message);
781
782 cfg.store
784 .store(
785 ¬ifiable_id,
786 notifiable_type,
787 &message.notification_type,
788 &db_msg,
789 )
790 .await?;
791
792 let channel = format!("user.{notifiable_id}");
794 let event = format!("Notification.{}", message.notification_type);
795 cfg.broker
796 .broadcast(&channel, &event, &message.data)
797 .await
798 .map_err(|e| Error::broadcast(e.to_string()))?;
799
800 info!(
801 notifiable_id = %notifiable_id,
802 notification_type = %message.notification_type,
803 "InApp notification persisted and broadcast"
804 );
805 Ok(())
806 }
807}
808
809fn inapp_to_database_message(msg: &InAppMessage) -> DatabaseMessage {
814 use std::collections::HashMap;
815 let data: HashMap<String, serde_json::Value> = if let serde_json::Value::Object(map) = &msg.data
816 {
817 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
818 } else {
819 let mut m = HashMap::new();
820 m.insert("payload".to_string(), msg.data.clone());
821 m
822 };
823 DatabaseMessage::new(&msg.notification_type).with_data(data)
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use serial_test::serial;
830
831 #[test]
832 fn test_mail_config_smtp_builder() {
833 let config = MailConfig::new("smtp.example.com", 587, "noreply@example.com")
834 .credentials("user", "pass")
835 .from_name("My App");
836
837 assert!(matches!(config.driver, MailDriver::Smtp));
838 assert_eq!(config.from, "noreply@example.com");
839 assert_eq!(config.from_name, Some("My App".to_string()));
840
841 let smtp = config.smtp.as_ref().unwrap();
842 assert_eq!(smtp.host, "smtp.example.com");
843 assert_eq!(smtp.port, 587);
844 assert_eq!(smtp.username, Some("user".to_string()));
845 assert_eq!(smtp.password, Some("pass".to_string()));
846 assert!(smtp.tls);
847 assert!(config.resend.is_none());
848 }
849
850 #[test]
851 fn test_mail_config_resend_builder() {
852 let config = MailConfig::resend("re_123456", "noreply@example.com").from_name("My App");
853
854 assert!(matches!(config.driver, MailDriver::Resend));
855 assert_eq!(config.from, "noreply@example.com");
856 assert_eq!(config.from_name, Some("My App".to_string()));
857
858 let resend = config.resend.as_ref().unwrap();
859 assert_eq!(resend.api_key, "re_123456");
860 assert!(config.smtp.is_none());
861 }
862
863 #[test]
864 fn test_mail_config_no_tls() {
865 let config = MailConfig::new("smtp.example.com", 587, "noreply@example.com").no_tls();
866
867 let smtp = config.smtp.as_ref().unwrap();
868 assert!(!smtp.tls);
869 }
870
871 #[test]
872 fn test_notification_config_default() {
873 let config = NotificationConfig::default();
874 assert!(config.mail.is_none());
875 assert!(config.slack_webhook.is_none());
876 assert!(!config.whatsapp_enabled);
877 assert!(config.in_app.is_none());
878 assert!(config.database_store.is_none());
879 }
880
881 #[test]
882 fn test_notification_config_with_database_store_builder() {
883 use crate::channels::DatabaseMessage;
884 use crate::notifiable::DatabaseNotificationStore;
885 use async_trait::async_trait;
886
887 struct NoopStore;
888 #[async_trait]
889 impl DatabaseNotificationStore for NoopStore {
890 async fn store(
891 &self,
892 _: &str,
893 _: &str,
894 _: &str,
895 _: &DatabaseMessage,
896 ) -> Result<(), Error> {
897 Ok(())
898 }
899 async fn mark_as_read(&self, _: &str) -> Result<(), Error> {
900 Ok(())
901 }
902 async fn unread(&self, _: &str) -> Result<Vec<crate::StoredNotification>, Error> {
903 Ok(vec![])
904 }
905 }
906
907 let store: Arc<dyn DatabaseNotificationStore> = Arc::new(NoopStore);
908 let config = NotificationConfig::new().with_database_store(store);
909 assert!(config.database_store.is_some());
910 }
911
912 #[test]
913 #[serial]
914 fn test_notification_config_whatsapp_enabled_from_env() {
915 unsafe { env::remove_var("WHATSAPP_ENABLED") };
916 with_env_vars(&[("WHATSAPP_ENABLED", "true")], || {
917 let config = NotificationConfig::from_env();
918 assert!(config.whatsapp_enabled);
919 });
920 }
921
922 #[test]
923 #[serial]
924 fn test_notification_config_whatsapp_disabled_when_env_false() {
925 unsafe { env::remove_var("WHATSAPP_ENABLED") };
926 with_env_vars(&[("WHATSAPP_ENABLED", "false")], || {
927 let config = NotificationConfig::from_env();
928 assert!(!config.whatsapp_enabled);
929 });
930 }
931
932 #[test]
933 #[serial]
934 fn test_notification_config_whatsapp_disabled_when_env_unset() {
935 unsafe { env::remove_var("WHATSAPP_ENABLED") };
936 let config = NotificationConfig::from_env();
937 assert!(!config.whatsapp_enabled);
938 }
939
940 #[test]
941 #[serial]
942 fn test_notification_config_whatsapp_disabled_when_env_garbage() {
943 unsafe { env::remove_var("WHATSAPP_ENABLED") };
944 with_env_vars(&[("WHATSAPP_ENABLED", "yes-please")], || {
945 let config = NotificationConfig::from_env();
946 assert!(
947 !config.whatsapp_enabled,
948 "non-bool string must fall back to false"
949 );
950 });
951 }
952
953 #[test]
954 fn test_notification_config_with_whatsapp_enabled_builder() {
955 let config = NotificationConfig::new().with_whatsapp_enabled(true);
956 assert!(config.whatsapp_enabled);
957 let config2 = NotificationConfig::new().with_whatsapp_enabled(false);
958 assert!(!config2.whatsapp_enabled);
959 }
960
961 fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
963 for (key, val) in vars {
965 unsafe { env::set_var(key, val) };
966 }
967 f();
968 for (key, _) in vars {
970 unsafe { env::remove_var(key) };
971 }
972 }
973
974 fn clean_mail_env() {
976 let keys = [
977 "MAIL_DRIVER",
978 "MAIL_FROM_ADDRESS",
979 "MAIL_FROM_NAME",
980 "MAIL_HOST",
981 "MAIL_PORT",
982 "MAIL_USERNAME",
983 "MAIL_PASSWORD",
984 "MAIL_ENCRYPTION",
985 "RESEND_API_KEY",
986 ];
987 for key in keys {
988 unsafe { env::remove_var(key) };
989 }
990 }
991
992 #[test]
993 #[serial]
994 fn test_mail_config_smtp_from_env() {
995 clean_mail_env();
996 with_env_vars(
997 &[
998 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
999 ("MAIL_FROM_NAME", "Test App"),
1000 ("MAIL_HOST", "smtp.example.com"),
1001 ("MAIL_PORT", "465"),
1002 ("MAIL_USERNAME", "user@example.com"),
1003 ("MAIL_PASSWORD", "secret"),
1004 ("MAIL_ENCRYPTION", "tls"),
1005 ],
1006 || {
1007 let config = MailConfig::from_env().expect("should parse SMTP config");
1008 assert!(matches!(config.driver, MailDriver::Smtp));
1009 assert_eq!(config.from, "noreply@example.com");
1010 assert_eq!(config.from_name, Some("Test App".to_string()));
1011
1012 let smtp = config.smtp.as_ref().expect("smtp config present");
1013 assert_eq!(smtp.host, "smtp.example.com");
1014 assert_eq!(smtp.port, 465);
1015 assert_eq!(smtp.username, Some("user@example.com".to_string()));
1016 assert_eq!(smtp.password, Some("secret".to_string()));
1017 assert!(smtp.tls);
1018 assert!(config.resend.is_none());
1019 },
1020 );
1021 }
1022
1023 #[test]
1024 #[serial]
1025 fn test_mail_config_resend_from_env() {
1026 clean_mail_env();
1027 with_env_vars(
1028 &[
1029 ("MAIL_DRIVER", "resend"),
1030 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
1031 ("MAIL_FROM_NAME", "Test App"),
1032 ("RESEND_API_KEY", "re_test_123456"),
1033 ],
1034 || {
1035 let config = MailConfig::from_env().expect("should parse Resend config");
1036 assert!(matches!(config.driver, MailDriver::Resend));
1037 assert_eq!(config.from, "noreply@example.com");
1038 assert_eq!(config.from_name, Some("Test App".to_string()));
1039
1040 let resend = config.resend.as_ref().expect("resend config present");
1041 assert_eq!(resend.api_key, "re_test_123456");
1042 assert!(config.smtp.is_none());
1043 },
1044 );
1045 }
1046
1047 #[test]
1048 #[serial]
1049 fn test_mail_config_default_driver() {
1050 clean_mail_env();
1051 with_env_vars(
1052 &[
1053 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
1054 ("MAIL_HOST", "smtp.example.com"),
1055 ],
1056 || {
1057 let config = MailConfig::from_env().expect("should default to SMTP");
1058 assert!(matches!(config.driver, MailDriver::Smtp));
1059 assert_eq!(config.smtp.as_ref().unwrap().host, "smtp.example.com");
1060 assert_eq!(config.smtp.as_ref().unwrap().port, 587); },
1062 );
1063 }
1064
1065 #[test]
1066 #[serial]
1067 fn test_mail_config_resend_missing_api_key() {
1068 clean_mail_env();
1069 with_env_vars(
1070 &[
1071 ("MAIL_DRIVER", "resend"),
1072 ("MAIL_FROM_ADDRESS", "noreply@example.com"),
1073 ],
1074 || {
1075 let config = MailConfig::from_env();
1076 assert!(
1077 config.is_none(),
1078 "should return None when RESEND_API_KEY missing"
1079 );
1080 },
1081 );
1082 }
1083
1084 #[test]
1085 fn test_resend_payload_serialization() {
1086 let payload = ResendEmailPayload {
1087 from: "sender@example.com".into(),
1088 to: vec!["recipient@example.com".into()],
1089 subject: "Test".into(),
1090 html: Some("<p>Hello</p>".into()),
1091 text: None,
1092 cc: vec![],
1093 bcc: vec![],
1094 reply_to: None,
1095 attachments: vec![],
1096 };
1097
1098 let json = serde_json::to_value(&payload).unwrap();
1099 assert_eq!(json["from"], "sender@example.com");
1100 assert_eq!(json["to"][0], "recipient@example.com");
1101 assert_eq!(json["subject"], "Test");
1102 assert_eq!(json["html"], "<p>Hello</p>");
1103 assert!(json.get("text").is_none());
1105 assert!(json.get("cc").is_none());
1106 assert!(json.get("bcc").is_none());
1107 assert!(json.get("reply_to").is_none());
1108 assert!(json.get("attachments").is_none());
1109 }
1110
1111 #[test]
1112 fn test_resend_payload_text_fallback() {
1113 let payload = ResendEmailPayload {
1114 from: "sender@example.com".into(),
1115 to: vec!["recipient@example.com".into()],
1116 subject: "Test".into(),
1117 html: None,
1118 text: Some("Plain text body".into()),
1119 cc: vec!["cc@example.com".into()],
1120 bcc: vec!["bcc@example.com".into()],
1121 reply_to: Some("reply@example.com".into()),
1122 attachments: vec![],
1123 };
1124
1125 let json = serde_json::to_value(&payload).unwrap();
1126 assert!(json.get("html").is_none());
1127 assert_eq!(json["text"], "Plain text body");
1128 assert_eq!(json["cc"][0], "cc@example.com");
1129 assert_eq!(json["bcc"][0], "bcc@example.com");
1130 assert_eq!(json["reply_to"], "reply@example.com");
1131 }
1132
1133 #[test]
1134 fn test_resend_payload_no_attachments_omits_field() {
1135 let payload = ResendEmailPayload {
1137 from: "sender@example.com".into(),
1138 to: vec!["recipient@example.com".into()],
1139 subject: "Test".into(),
1140 html: Some("<p>Hello</p>".into()),
1141 text: None,
1142 cc: vec![],
1143 bcc: vec![],
1144 reply_to: None,
1145 attachments: vec![],
1146 };
1147 let json = serde_json::to_value(&payload).unwrap();
1148 assert!(
1149 json.get("attachments").is_none(),
1150 "Empty attachments must not appear in serialized payload (byte-identical-to-today guarantee)"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_resend_payload_with_attachments_serializes_base64() {
1156 let payload = ResendEmailPayload {
1157 from: "sender@example.com".into(),
1158 to: vec!["recipient@example.com".into()],
1159 subject: "Test".into(),
1160 html: None,
1161 text: Some("body".into()),
1162 cc: vec![],
1163 bcc: vec![],
1164 reply_to: None,
1165 attachments: vec![ResendAttachment {
1166 filename: "hi.txt".into(),
1167 content: "aGVsbG8=".into(),
1169 }],
1170 };
1171 let json = serde_json::to_value(&payload).unwrap();
1172 assert_eq!(json["attachments"][0]["filename"], "hi.txt");
1173 assert_eq!(json["attachments"][0]["content"], "aGVsbG8=");
1174 assert_eq!(json["attachments"].as_array().unwrap().len(), 1);
1175 }
1176
1177 #[test]
1178 fn test_base64_encoding_uses_standard_alphabet() {
1179 use base64::Engine;
1180 let encoded =
1184 base64::engine::general_purpose::STANDARD.encode(b"Many hands make light work.");
1185 assert_eq!(encoded, "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu");
1186 }
1187
1188 #[test]
1189 fn test_send_whatsapp_disabled_returns_ok_without_calling_init() {
1190 let config = NotificationConfig::default();
1199 assert!(
1200 !config.whatsapp_enabled,
1201 "Default whatsapp_enabled must be false so dispatch path is gated"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_smtp_multipart_path_compiles_with_attachment() {
1207 use crate::channels::MailMessage;
1210 let mail = MailMessage::new()
1211 .subject("Test")
1212 .body("Hello")
1213 .attachment("test.txt", "text/plain", b"hello".to_vec())
1214 .expect("under-limit attachment must succeed");
1215 assert_eq!(mail.attachments.len(), 1);
1216 assert_eq!(mail.attachments[0].content_type, "text/plain");
1217 }
1218
1219 #[test]
1220 fn test_inapp_to_database_message_object_data_flattens() {
1221 use crate::channels::{InAppMessage, InAppSeverity};
1222 let msg = InAppMessage::new("OrderShipped")
1223 .data(serde_json::json!({"order_id": 42, "tracking": "ABC"}))
1224 .severity(InAppSeverity::Success);
1225 let db = inapp_to_database_message(&msg);
1226 assert_eq!(db.notification_type, "OrderShipped");
1227 assert_eq!(db.get("order_id"), Some(&serde_json::json!(42)));
1228 assert_eq!(db.get("tracking"), Some(&serde_json::json!("ABC")));
1229 assert!(
1230 db.get("payload").is_none(),
1231 "object data must NOT be wrapped under 'payload'"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_inapp_to_database_message_non_object_wraps_under_payload() {
1237 use crate::channels::InAppMessage;
1238 let msg = InAppMessage::new("Heartbeat").data(serde_json::json!("ping"));
1239 let db = inapp_to_database_message(&msg);
1240 assert_eq!(db.notification_type, "Heartbeat");
1241 assert_eq!(db.get("payload"), Some(&serde_json::json!("ping")));
1242 }
1243
1244 #[tokio::test]
1245 async fn test_send_database_calls_store_when_configured() {
1246 use crate::channels::DatabaseMessage;
1252 use crate::notifiable::DatabaseNotificationStore;
1253 use async_trait::async_trait;
1254 use std::sync::atomic::{AtomicUsize, Ordering};
1255
1256 struct CountingStore {
1257 calls: AtomicUsize,
1258 }
1259 #[async_trait]
1260 impl DatabaseNotificationStore for CountingStore {
1261 async fn store(
1262 &self,
1263 _: &str,
1264 _: &str,
1265 _: &str,
1266 _: &DatabaseMessage,
1267 ) -> Result<(), Error> {
1268 self.calls.fetch_add(1, Ordering::SeqCst);
1269 Ok(())
1270 }
1271 async fn mark_as_read(&self, _: &str) -> Result<(), Error> {
1272 Ok(())
1273 }
1274 async fn unread(&self, _: &str) -> Result<Vec<crate::StoredNotification>, Error> {
1275 Ok(vec![])
1276 }
1277 }
1278
1279 let store = Arc::new(CountingStore {
1280 calls: AtomicUsize::new(0),
1281 });
1282 let msg = DatabaseMessage::new("Test").data("k", "v");
1283 store
1284 .store("user_id", "User", &msg.notification_type, &msg)
1285 .await
1286 .unwrap();
1287 assert_eq!(store.calls.load(Ordering::SeqCst), 1);
1288 }
1289}