Skip to main content

ferro_notifications/
dispatcher.rs

1//! Notification dispatcher for sending notifications through channels.
2
3use crate::channel::Channel;
4use crate::channels::{MailMessage, SlackMessage};
5use crate::notifiable::Notifiable;
6use crate::notification::Notification;
7use crate::Error;
8use serde::Serialize;
9use std::env;
10use std::sync::OnceLock;
11use tracing::{error, info};
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}
24
25/// Mail transport driver.
26#[derive(Debug, Clone, Default)]
27pub enum MailDriver {
28    /// SMTP via lettre (default).
29    #[default]
30    Smtp,
31    /// Resend HTTP API.
32    Resend,
33}
34
35/// SMTP-specific configuration.
36#[derive(Clone)]
37pub struct SmtpConfig {
38    /// SMTP host.
39    pub host: String,
40    /// SMTP port.
41    pub port: u16,
42    /// SMTP username.
43    pub username: Option<String>,
44    /// SMTP password.
45    pub password: Option<String>,
46    /// Use TLS.
47    pub tls: bool,
48}
49
50/// Resend-specific configuration.
51#[derive(Clone)]
52pub struct ResendConfig {
53    /// Resend API key.
54    pub api_key: String,
55}
56
57/// Mail configuration supporting multiple drivers.
58#[derive(Clone)]
59pub struct MailConfig {
60    /// Which driver to use.
61    pub driver: MailDriver,
62    /// Default from address (shared across all drivers).
63    pub from: String,
64    /// Default from name (shared across all drivers).
65    pub from_name: Option<String>,
66    /// SMTP-specific config (only when driver = Smtp).
67    pub smtp: Option<SmtpConfig>,
68    /// Resend-specific config (only when driver = Resend).
69    pub resend: Option<ResendConfig>,
70}
71
72impl NotificationConfig {
73    /// Create a new notification config.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Create configuration from environment variables.
79    ///
80    /// Reads the following environment variables:
81    /// - Mail: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`,
82    ///   `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME`, `MAIL_ENCRYPTION`
83    /// - Slack: `SLACK_WEBHOOK_URL`
84    ///
85    /// # Example
86    ///
87    /// ```rust,ignore
88    /// use ferro_notifications::NotificationConfig;
89    ///
90    /// // In bootstrap.rs
91    /// let config = NotificationConfig::from_env();
92    /// NotificationDispatcher::configure(config);
93    /// ```
94    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    /// Set the mail configuration.
102    pub fn mail(mut self, config: MailConfig) -> Self {
103        self.mail = Some(config);
104        self
105    }
106
107    /// Set the Slack webhook URL.
108    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    /// Create a new SMTP mail config (backwards compatible).
116    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    /// Create a new Resend mail config.
133    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    /// Create mail configuration from environment variables.
146    ///
147    /// Returns `None` if required variables are missing.
148    ///
149    /// Reads the following environment variables:
150    /// - `MAIL_DRIVER`: "smtp" (default) or "resend"
151    /// - `MAIL_FROM_ADDRESS`: Default from email address (required for all drivers)
152    /// - `MAIL_FROM_NAME`: Default from name (optional)
153    ///
154    /// SMTP driver variables:
155    /// - `MAIL_HOST`: SMTP server host (required)
156    /// - `MAIL_PORT`: SMTP server port (default: 587)
157    /// - `MAIL_USERNAME`: SMTP username (optional)
158    /// - `MAIL_PASSWORD`: SMTP password (optional)
159    /// - `MAIL_ENCRYPTION`: "tls" or "none" (default: "tls")
160    ///
161    /// Resend driver variables:
162    /// - `RESEND_API_KEY`: Resend API key (required)
163    ///
164    /// # Example
165    ///
166    /// ```rust,ignore
167    /// use ferro_notifications::MailConfig;
168    ///
169    /// if let Some(config) = MailConfig::from_env() {
170    ///     // Mail is configured
171    /// }
172    /// ```
173    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                // Default: SMTP (backwards compatible)
198                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    /// Set SMTP credentials.
230    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    /// Set the from name.
244    pub fn from_name(mut self, name: impl Into<String>) -> Self {
245        self.from_name = Some(name.into());
246        self
247    }
248
249    /// Disable TLS (SMTP only).
250    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/// Resend API email payload.
259#[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
276/// The notification dispatcher.
277pub struct NotificationDispatcher;
278
279impl NotificationDispatcher {
280    /// Configure the global notification dispatcher.
281    pub fn configure(config: NotificationConfig) {
282        let _ = CONFIG.set(config);
283    }
284
285    /// Get the current configuration.
286    pub fn config() -> Option<&'static NotificationConfig> {
287        CONFIG.get()
288    }
289
290    /// Send a notification to a notifiable entity.
291    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                    // Not implemented yet
324                    info!(channel = %channel, "Channel not implemented");
325                }
326            }
327        }
328
329        Ok(())
330    }
331
332    /// Send a mail notification, dispatching to the configured driver.
333    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    /// Send mail via SMTP using lettre.
355    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    /// Send mail via Resend HTTP API.
449    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    /// Send a database notification.
503    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        // In a real implementation, this would store to the database.
517        // For now, we just log it. The user should implement DatabaseNotificationStore.
518        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    /// Send a Slack notification.
530    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    /// Helper to run env-based tests with clean env var state.
614    fn with_env_vars<F: FnOnce()>(vars: &[(&str, &str)], f: F) {
615        // Set vars
616        for (key, val) in vars {
617            unsafe { env::set_var(key, val) };
618        }
619        f();
620        // Clean up
621        for (key, _) in vars {
622            unsafe { env::remove_var(key) };
623        }
624    }
625
626    /// Helper to ensure env vars are clean before a test.
627    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); // default port
713            },
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        // skip_serializing_if fields should be absent
755        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}