Skip to main content

ferro_notifications/
dispatcher.rs

1//! Notification dispatcher for sending notifications through channels.
2
3use 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
13/// Global notification dispatcher configuration.
14static CONFIG: OnceLock<NotificationConfig> = OnceLock::new();
15
16/// Configuration for the notification dispatcher.
17#[derive(Clone, Default)]
18pub struct NotificationConfig {
19    /// Mail configuration (supports SMTP and Resend drivers).
20    pub mail: Option<MailConfig>,
21    /// Slack webhook URL.
22    pub slack_webhook: Option<String>,
23    /// Enable the WhatsApp channel (per CONTEXT.md D-04).
24    ///
25    /// Defaults to `false`. When `true`, the dispatcher calls
26    /// [`ferro_whatsapp::WhatsApp::send`] which requires that
27    /// [`ferro_whatsapp::WhatsApp::init`] was called at app startup.
28    pub whatsapp_enabled: bool,
29    /// In-app SSE channel configuration (per CONTEXT.md D-07).
30    ///
31    /// When `Some`, `Channel::InApp` dispatches persist via `store` then publish via `broker`.
32    /// When `None`, `Channel::InApp` emits a structured "channel not configured" no-op.
33    pub in_app: Option<InAppConfig>,
34    /// Database notification store (per CONTEXT.md D-13, closes ARCH-FINDING-02).
35    ///
36    /// When `Some`, `Channel::Database` calls `store.store(...)` instead of the placeholder log.
37    /// When `None`, the existing placeholder behavior is preserved (backward-compat).
38    /// Note: `InAppConfig.store` is independent of this field — they may point to the same Arc
39    /// or to different stores.
40    pub database_store: Option<Arc<dyn DatabaseNotificationStore>>,
41}
42
43/// In-app SSE channel configuration.
44///
45/// Combines a `Broadcaster` for real-time fanout and a `DatabaseNotificationStore`
46/// for persistence. Per CONTEXT.md D-08, `Channel::InApp` dispatches write the DB-store
47/// leg first, then publish to the broadcast channel `format!("user.{}", notifiable_id)`.
48#[derive(Clone)]
49pub struct InAppConfig {
50    /// Broadcaster handle for SSE / WebSocket fanout.
51    pub broker: Arc<ferro_broadcast::Broadcaster>,
52    /// Persistence store — typically the same Arc as `NotificationConfig::database_store`.
53    pub store: Arc<dyn DatabaseNotificationStore>,
54}
55
56/// Mail transport driver.
57#[derive(Debug, Clone, Default)]
58pub enum MailDriver {
59    /// SMTP via lettre (default).
60    #[default]
61    Smtp,
62    /// Resend HTTP API.
63    Resend,
64}
65
66/// SMTP-specific configuration.
67#[derive(Clone)]
68pub struct SmtpConfig {
69    /// SMTP host.
70    pub host: String,
71    /// SMTP port.
72    pub port: u16,
73    /// SMTP username.
74    pub username: Option<String>,
75    /// SMTP password.
76    pub password: Option<String>,
77    /// Use TLS.
78    pub tls: bool,
79}
80
81/// Resend-specific configuration.
82#[derive(Clone)]
83pub struct ResendConfig {
84    /// Resend API key.
85    pub api_key: String,
86}
87
88/// Mail configuration supporting multiple drivers.
89#[derive(Clone)]
90pub struct MailConfig {
91    /// Which driver to use.
92    pub driver: MailDriver,
93    /// Default from address (shared across all drivers).
94    pub from: String,
95    /// Default from name (shared across all drivers).
96    pub from_name: Option<String>,
97    /// SMTP-specific config (only when driver = Smtp).
98    pub smtp: Option<SmtpConfig>,
99    /// Resend-specific config (only when driver = Resend).
100    pub resend: Option<ResendConfig>,
101}
102
103impl NotificationConfig {
104    /// Create a new notification config.
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Create configuration from environment variables.
110    ///
111    /// Reads the following environment variables:
112    /// - Mail: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`,
113    ///   `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME`, `MAIL_ENCRYPTION`
114    /// - Slack: `SLACK_WEBHOOK_URL`
115    ///
116    /// # Example
117    ///
118    /// ```rust,ignore
119    /// use ferro_notifications::NotificationConfig;
120    ///
121    /// // In bootstrap.rs
122    /// let config = NotificationConfig::from_env();
123    /// NotificationDispatcher::configure(config);
124    /// ```
125    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` and `database_store` require typed handles (per CONTEXT.md D-14)
134            // — they are not env-driven.
135            in_app: None,
136            database_store: None,
137        }
138    }
139
140    /// Set the mail configuration.
141    pub fn mail(mut self, config: MailConfig) -> Self {
142        self.mail = Some(config);
143        self
144    }
145
146    /// Set the Slack webhook URL.
147    pub fn slack_webhook(mut self, url: impl Into<String>) -> Self {
148        self.slack_webhook = Some(url.into());
149        self
150    }
151
152    /// Enable or disable the WhatsApp channel.
153    pub fn with_whatsapp_enabled(mut self, enabled: bool) -> Self {
154        self.whatsapp_enabled = enabled;
155        self
156    }
157
158    /// Set the in-app channel configuration (per CONTEXT.md D-07).
159    pub fn with_in_app(mut self, config: InAppConfig) -> Self {
160        self.in_app = Some(config);
161        self
162    }
163
164    /// Set the database notification store (per CONTEXT.md D-13).
165    ///
166    /// When configured, `Channel::Database` dispatches call `store.store(...)`
167    /// instead of the placeholder log path.
168    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    /// Create a new SMTP mail config (backwards compatible).
176    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    /// Create a new Resend mail config.
193    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    /// Create mail configuration from environment variables.
206    ///
207    /// Returns `None` if required variables are missing.
208    ///
209    /// Reads the following environment variables:
210    /// - `MAIL_DRIVER`: "smtp" (default) or "resend"
211    /// - `MAIL_FROM_ADDRESS`: Default from email address (required for all drivers)
212    /// - `MAIL_FROM_NAME`: Default from name (optional)
213    ///
214    /// SMTP driver variables:
215    /// - `MAIL_HOST`: SMTP server host (required)
216    /// - `MAIL_PORT`: SMTP server port (default: 587)
217    /// - `MAIL_USERNAME`: SMTP username (optional)
218    /// - `MAIL_PASSWORD`: SMTP password (optional)
219    /// - `MAIL_ENCRYPTION`: "tls" or "none" (default: "tls")
220    ///
221    /// Resend driver variables:
222    /// - `RESEND_API_KEY`: Resend API key (required)
223    ///
224    /// # Example
225    ///
226    /// ```rust,ignore
227    /// use ferro_notifications::MailConfig;
228    ///
229    /// if let Some(config) = MailConfig::from_env() {
230    ///     // Mail is configured
231    /// }
232    /// ```
233    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                // Default: SMTP (backwards compatible)
258                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    /// Set SMTP credentials.
290    ///
291    /// No-ops with a warning when the driver is not [`MailDriver::Smtp`] — calling
292    /// `credentials(...)` on a Resend config is almost certainly a caller mistake
293    /// and should not silently mutate the config into an SMTP shape.
294    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    /// Set the from name.
312    pub fn from_name(mut self, name: impl Into<String>) -> Self {
313        self.from_name = Some(name.into());
314        self
315    }
316
317    /// Disable TLS (SMTP only).
318    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/// Resend API attachment payload.
327#[derive(Serialize)]
328struct ResendAttachment {
329    filename: String,
330    /// Base64-encoded attachment content (standard alphabet, not URL-safe).
331    content: String,
332}
333
334/// Resend API email payload.
335#[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
354/// The notification dispatcher.
355pub struct NotificationDispatcher;
356
357impl NotificationDispatcher {
358    /// Configure the global notification dispatcher.
359    pub fn configure(config: NotificationConfig) {
360        let _ = CONFIG.set(config);
361    }
362
363    /// Get the current configuration.
364    pub fn config() -> Option<&'static NotificationConfig> {
365        CONFIG.get()
366    }
367
368    /// Send a notification to a notifiable entity.
369    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                    // Per ARCH-FINDING-03 — not implemented in this phase.
412                    info!(channel = %channel, "Channel not implemented");
413                }
414            }
415        }
416
417        Ok(())
418    }
419
420    /// Send a mail notification, dispatching to the configured driver.
421    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    /// Send mail via SMTP using lettre.
443    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        // Body part — single-part for backward-compat when no attachments,
499        // SinglePart wrapped in MultiPart::mixed otherwise.
500        let email = if message.attachments.is_empty() {
501            // Backward-compatible single-part path
502            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            // Multipart path — body becomes a SinglePart inside MultiPart::mixed
515            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    /// Send mail via Resend HTTP API.
562    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        // Parse the success body to extract the Resend message id (correlation token
624        // for downstream debugging). Failure to parse is non-fatal: the send already
625        // succeeded per the 2xx status, so we log and continue without an id.
626        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    /// Send a database notification.
643    ///
644    /// Per CONTEXT.md D-13 / ARCH-FINDING-02, this calls
645    /// [`DatabaseNotificationStore::store`] when `NotificationConfig::database_store`
646    /// is configured. When unconfigured, retains the original placeholder log
647    /// (backward-compat: no consumer of the placeholder is broken).
648    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                    &notifiable_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    /// Send a Slack notification.
684    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    /// Send a WhatsApp notification via the static `ferro_whatsapp::WhatsApp` facade.
715    ///
716    /// Per CONTEXT.md D-04 / ARCH-FINDING-01, the adapter does NOT inject a client.
717    /// `ferro_whatsapp::WhatsApp` owns its global state via `WhatsApp::init` (called
718    /// once at app startup). The `whatsapp_enabled: false` default ensures this code
719    /// path is unreachable unless the consumer opted in.
720    ///
721    /// **Retry is the caller's responsibility.** This dispatcher performs no backoff,
722    /// jitter, or retry on transient failures. Match on the inner [`ferro_whatsapp::Error`]
723    /// via `Error::WhatsApp(inner)` to distinguish retryable variants (e.g. `RateLimit`,
724    /// `NetworkError`) from terminal ones (e.g. invalid phone number); recommended path
725    /// for retry is to enqueue the send through `ferro-queue`.
726    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        // The Error::WhatsApp(#[from]) conversion (Plan 02) handles the propagation.
744        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    /// Send an in-app notification (per CONTEXT.md D-06, D-07, D-08).
750    ///
751    /// Writes both legs:
752    /// 1. Persists via [`DatabaseNotificationStore::store`] (DB leg first — broker can replay
753    ///    on reconnect from the store; the inverse order would risk silent loss).
754    /// 2. Publishes via `Broadcaster::broadcast` to channel `user.{id}` with event
755    ///    `Notification.{notification_type}` and the InAppMessage `data` as payload.
756    ///
757    /// Returns the first error encountered. The legs are NOT transactional: if the DB
758    /// write succeeds and the broadcast then fails, the persisted row remains and a
759    /// naive retry will create a duplicate. Per CONTEXT.md D-08 this is intentional —
760    /// the broker can replay from the store on reconnect — but callers performing
761    /// manual retries should dedupe on (notifiable_id, notification_type, idempotency-key).
762    /// `ferro_broadcast::Error` is mapped via [`Error::broadcast`] (no `#[from]` available).
763    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        // Convert InAppMessage to DatabaseMessage for persistence.
779        // Per RESEARCH.md: object data → HashMap of fields; non-object → wrap as { "payload": value }.
780        let db_msg = inapp_to_database_message(message);
781
782        // Leg 1: persistence (DB-store first per D-08).
783        cfg.store
784            .store(
785                &notifiable_id,
786                notifiable_type,
787                &message.notification_type,
788                &db_msg,
789            )
790            .await?;
791
792        // Leg 2: real-time fanout.
793        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
809/// Convert an [`InAppMessage`] to a [`DatabaseMessage`] for persistence.
810///
811/// If `msg.data` is a JSON object, its fields become the DatabaseMessage data map.
812/// Otherwise, the value is wrapped under the key `"payload"`.
813fn 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    /// Helper to run env-based tests with clean env var state.
962    fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
963        // Set vars
964        for (key, val) in vars {
965            unsafe { env::set_var(key, val) };
966        }
967        f();
968        // Clean up
969        for (key, _) in vars {
970            unsafe { env::remove_var(key) };
971        }
972    }
973
974    /// Helper to ensure env vars are clean before a test.
975    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); // default port
1061            },
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        // skip_serializing_if fields should be absent
1104        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        // Regression guard: when attachments empty, the JSON payload has NO "attachments" key.
1136        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                // "hello" -> base64 standard = "aGVsbG8="
1168                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        // Verify a known fixture: "Many hands make light work."
1181        // Standard base64: "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu"
1182        // URL-safe would substitute / and + characters; standard is what Resend expects.
1183        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        // The behavior contract: when whatsapp_enabled is false, send_whatsapp must NOT
1191        // touch ferro_whatsapp at all. We verify this indirectly by checking that
1192        // NotificationConfig::default().whatsapp_enabled is false (already covered by
1193        // test_notification_config_default), and that the dispatcher gating is
1194        // observable through the public `whatsapp_enabled` field.
1195        //
1196        // A live integration test that sends a real WhatsApp message lives downstream
1197        // in gestiscilo-it Phase 120 (per ROADMAP success criterion #7).
1198        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        // Smoke test — actual SMTP send is exercised by the Mailpit integration test in Plan 07.
1208        // Here we just construct a MailMessage with an attachment and verify the type compiles end-to-end.
1209        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        // Substantive behavior — store.store() is invoked with the dispatcher's wiring —
1247        // is best exercised at the consumer integration level (gestiscilo-it Phase 120).
1248        // Since CONFIG is a OnceLock global, we cannot inject a fresh store per test
1249        // without restructuring the dispatcher. Here we verify the *path* exists by
1250        // exercising the trait directly through the public builder shape.
1251        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}