Skip to main content

construct/channels/
email_channel.rs

1#![allow(clippy::uninlined_format_args)]
2#![allow(clippy::map_unwrap_or)]
3#![allow(clippy::redundant_closure_for_method_calls)]
4#![allow(clippy::cast_lossless)]
5#![allow(clippy::trim_split_whitespace)]
6#![allow(clippy::doc_link_with_quotes)]
7#![allow(clippy::doc_markdown)]
8#![allow(clippy::too_many_lines)]
9#![allow(clippy::unnecessary_map_or)]
10
11use anyhow::{Result, anyhow};
12use async_imap::Session;
13use async_imap::extensions::idle::IdleResponse;
14use async_imap::types::Fetch;
15use async_trait::async_trait;
16use futures_util::TryStreamExt;
17use lettre::message::SinglePart;
18use lettre::transport::smtp::authentication::Credentials;
19use lettre::{Message, SmtpTransport, Transport};
20use mail_parser::{MessageParser, MimeHeaders};
21use rustls::{ClientConfig, RootCertStore};
22use rustls_pki_types::DnsName;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use std::collections::HashSet;
26use std::sync::Arc;
27use std::time::{Duration, SystemTime, UNIX_EPOCH};
28use tokio::net::TcpStream;
29use tokio::sync::{Mutex, mpsc};
30use tokio::time::{sleep, timeout};
31use tokio_rustls::TlsConnector;
32use tokio_rustls::client::TlsStream;
33use tracing::{debug, error, info, warn};
34use uuid::Uuid;
35
36use super::traits::{Channel, ChannelMessage, SendMessage};
37
38/// Email channel configuration
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
40pub struct EmailConfig {
41    /// IMAP server hostname
42    pub imap_host: String,
43    /// IMAP server port (default: 993 for TLS)
44    #[serde(default = "default_imap_port")]
45    pub imap_port: u16,
46    /// IMAP folder to poll (default: INBOX)
47    #[serde(default = "default_imap_folder")]
48    pub imap_folder: String,
49    /// SMTP server hostname
50    pub smtp_host: String,
51    /// SMTP server port (default: 465 for TLS)
52    #[serde(default = "default_smtp_port")]
53    pub smtp_port: u16,
54    /// Use TLS for SMTP (default: true)
55    #[serde(default = "default_true")]
56    pub smtp_tls: bool,
57    /// Email username for authentication
58    pub username: String,
59    /// Email password for authentication
60    pub password: String,
61    /// From address for outgoing emails
62    pub from_address: String,
63    /// IDLE timeout in seconds before re-establishing connection (default: 1740 = 29 minutes)
64    /// RFC 2177 recommends clients restart IDLE every 29 minutes
65    #[serde(default = "default_idle_timeout", alias = "poll_interval_secs")]
66    pub idle_timeout_secs: u64,
67    /// Allowed sender addresses/domains (empty = deny all, ["*"] = allow all)
68    #[serde(default)]
69    pub allowed_senders: Vec<String>,
70    /// Default subject line for outgoing emails (default: "Construct Message")
71    #[serde(default = "default_subject")]
72    pub default_subject: String,
73}
74
75impl crate::config::traits::ChannelConfig for EmailConfig {
76    fn name() -> &'static str {
77        "Email"
78    }
79    fn desc() -> &'static str {
80        "Email over IMAP/SMTP"
81    }
82}
83
84fn default_imap_port() -> u16 {
85    993
86}
87fn default_smtp_port() -> u16 {
88    465
89}
90fn default_imap_folder() -> String {
91    "INBOX".into()
92}
93fn default_idle_timeout() -> u64 {
94    1740 // 29 minutes per RFC 2177
95}
96fn default_true() -> bool {
97    true
98}
99fn default_subject() -> String {
100    "Construct Message".into()
101}
102
103impl Default for EmailConfig {
104    fn default() -> Self {
105        Self {
106            imap_host: String::new(),
107            imap_port: default_imap_port(),
108            imap_folder: default_imap_folder(),
109            smtp_host: String::new(),
110            smtp_port: default_smtp_port(),
111            smtp_tls: true,
112            username: String::new(),
113            password: String::new(),
114            from_address: String::new(),
115            idle_timeout_secs: default_idle_timeout(),
116            allowed_senders: Vec::new(),
117            default_subject: default_subject(),
118        }
119    }
120}
121
122type ImapSession = Session<TlsStream<TcpStream>>;
123
124/// Email channel — IMAP IDLE for instant push notifications, SMTP for outbound
125pub struct EmailChannel {
126    pub config: EmailConfig,
127    seen_messages: Arc<Mutex<HashSet<String>>>,
128}
129
130impl EmailChannel {
131    pub fn new(config: EmailConfig) -> Self {
132        Self {
133            config,
134            seen_messages: Arc::new(Mutex::new(HashSet::new())),
135        }
136    }
137
138    /// Check if a sender email is in the allowlist
139    pub fn is_sender_allowed(&self, email: &str) -> bool {
140        if self.config.allowed_senders.is_empty() {
141            return false; // Empty = deny all
142        }
143        if self.config.allowed_senders.iter().any(|a| a == "*") {
144            return true; // Wildcard = allow all
145        }
146        let email_lower = email.to_lowercase();
147        self.config.allowed_senders.iter().any(|allowed| {
148            if allowed.starts_with('@') {
149                // Domain match with @ prefix: "@example.com"
150                email_lower.ends_with(&allowed.to_lowercase())
151            } else if allowed.contains('@') {
152                // Full email address match
153                allowed.eq_ignore_ascii_case(email)
154            } else {
155                // Domain match without @ prefix: "example.com"
156                email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
157            }
158        })
159    }
160
161    /// Strip HTML tags from content (basic)
162    pub fn strip_html(html: &str) -> String {
163        let mut result = String::new();
164        let mut in_tag = false;
165        for ch in html.chars() {
166            match ch {
167                '<' => in_tag = true,
168                '>' => in_tag = false,
169                _ if !in_tag => result.push(ch),
170                _ => {}
171            }
172        }
173        let mut normalized = String::with_capacity(result.len());
174        for word in result.split_whitespace() {
175            if !normalized.is_empty() {
176                normalized.push(' ');
177            }
178            normalized.push_str(word);
179        }
180        normalized
181    }
182
183    /// Extract the sender address from a parsed email
184    fn extract_sender(parsed: &mail_parser::Message) -> String {
185        parsed
186            .from()
187            .and_then(|addr| addr.first())
188            .and_then(|a| a.address())
189            .map(|s| s.to_string())
190            .unwrap_or_else(|| "unknown".into())
191    }
192
193    /// Extract readable text from a parsed email
194    fn extract_text(parsed: &mail_parser::Message) -> String {
195        if let Some(text) = parsed.body_text(0) {
196            return text.to_string();
197        }
198        if let Some(html) = parsed.body_html(0) {
199            return Self::strip_html(html.as_ref());
200        }
201        for part in parsed.attachments() {
202            let part: &mail_parser::MessagePart = part;
203            if let Some(ct) = MimeHeaders::content_type(part) {
204                if ct.ctype() == "text" {
205                    if let Ok(text) = std::str::from_utf8(part.contents()) {
206                        let name = MimeHeaders::attachment_name(part).unwrap_or("file");
207                        return format!("[Attachment: {}]\n{}", name, text);
208                    }
209                }
210            }
211        }
212        "(no readable content)".to_string()
213    }
214
215    /// Connect to IMAP server with TLS and authenticate
216    async fn connect_imap(&self) -> Result<ImapSession> {
217        let addr = format!("{}:{}", self.config.imap_host, self.config.imap_port);
218        debug!("Connecting to IMAP server at {}", addr);
219
220        // Connect TCP
221        let tcp = TcpStream::connect(&addr).await?;
222
223        // Establish TLS using rustls
224        let certs = RootCertStore {
225            roots: webpki_roots::TLS_SERVER_ROOTS.into(),
226        };
227        let config = ClientConfig::builder()
228            .with_root_certificates(certs)
229            .with_no_client_auth();
230        let tls_stream: TlsConnector = Arc::new(config).into();
231        let sni: DnsName = self.config.imap_host.clone().try_into()?;
232        let stream = tls_stream.connect(sni.into(), tcp).await?;
233
234        // Create IMAP client
235        let client = async_imap::Client::new(stream);
236
237        // Login
238        let session = client
239            .login(&self.config.username, &self.config.password)
240            .await
241            .map_err(|(e, _)| anyhow!("IMAP login failed: {}", e))?;
242
243        debug!("IMAP login successful");
244        Ok(session)
245    }
246
247    /// Maximum number of messages fetched per IMAP round-trip.
248    /// Bounds peak memory when the mailbox has a large unseen backlog.
249    const MAX_FETCH_BATCH: usize = 10;
250
251    /// Fetch and process unseen messages from the selected mailbox.
252    ///
253    /// UIDs are fetched in chunks of [`Self::MAX_FETCH_BATCH`] to bound the
254    /// number of message bodies (and any audio attachments) held in memory at
255    /// once. Each chunk is marked `\Seen` immediately after fetch so that
256    /// successfully retrieved messages are not re-fetched if a later chunk fails.
257    async fn fetch_unseen(&self, session: &mut ImapSession) -> Result<Vec<ParsedEmail>> {
258        // Search for unseen messages
259        let uids = session.uid_search("UNSEEN").await?;
260        if uids.is_empty() {
261            return Ok(Vec::new());
262        }
263
264        debug!("Found {} unseen messages", uids.len());
265
266        let uid_list: Vec<u32> = uids.into_iter().collect();
267        let mut results = Vec::new();
268
269        for chunk in uid_list.chunks(Self::MAX_FETCH_BATCH) {
270            let uid_set: String = chunk
271                .iter()
272                .map(|u| u.to_string())
273                .collect::<Vec<_>>()
274                .join(",");
275
276            // Fetch message bodies for this chunk
277            let messages = session.uid_fetch(&uid_set, "RFC822").await?;
278            let messages: Vec<Fetch> = messages.try_collect().await?;
279
280            for msg in messages {
281                let uid = msg.uid.unwrap_or(0);
282                if let Some(body) = msg.body() {
283                    if let Some(parsed) = MessageParser::default().parse(body) {
284                        let sender = Self::extract_sender(&parsed);
285                        let subject = parsed.subject().unwrap_or("(no subject)").to_string();
286                        let body_text = Self::extract_text(&parsed);
287                        let content = format!("Subject: {}\n\n{}", subject, body_text);
288                        let msg_id = parsed
289                            .message_id()
290                            .map(|s| s.to_string())
291                            .unwrap_or_else(|| format!("gen-{}", Uuid::new_v4()));
292
293                        #[allow(clippy::cast_sign_loss)]
294                        let ts = parsed
295                            .date()
296                            .map(|d| {
297                                let naive = chrono::NaiveDate::from_ymd_opt(
298                                    d.year as i32,
299                                    u32::from(d.month),
300                                    u32::from(d.day),
301                                )
302                                .and_then(|date| {
303                                    date.and_hms_opt(
304                                        u32::from(d.hour),
305                                        u32::from(d.minute),
306                                        u32::from(d.second),
307                                    )
308                                });
309                                naive.map_or(0, |n| n.and_utc().timestamp() as u64)
310                            })
311                            .unwrap_or_else(|| {
312                                SystemTime::now()
313                                    .duration_since(UNIX_EPOCH)
314                                    .map(|d| d.as_secs())
315                                    .unwrap_or(0)
316                            });
317
318                        results.push(ParsedEmail {
319                            _uid: uid,
320                            msg_id,
321                            sender,
322                            content,
323                            timestamp: ts,
324                        });
325                    }
326                }
327            }
328
329            // Mark this chunk as seen before fetching the next
330            let _ = session
331                .uid_store(&uid_set, "+FLAGS (\\Seen)")
332                .await?
333                .try_collect::<Vec<_>>()
334                .await;
335        }
336
337        Ok(results)
338    }
339
340    /// Run the IDLE loop, returning when a new message arrives or timeout
341    /// Note: IDLE consumes the session and returns it via done()
342    async fn wait_for_changes(
343        &self,
344        session: ImapSession,
345    ) -> Result<(IdleWaitResult, ImapSession)> {
346        let idle_timeout = Duration::from_secs(self.config.idle_timeout_secs);
347
348        // Start IDLE mode - this consumes the session
349        let mut idle = session.idle();
350        idle.init().await?;
351
352        debug!("Entering IMAP IDLE mode");
353
354        // wait() returns (future, stop_source) - we only need the future
355        let (wait_future, _stop_source) = idle.wait();
356
357        // Wait for server notification or timeout
358        let result = timeout(idle_timeout, wait_future).await;
359
360        match result {
361            Ok(Ok(response)) => {
362                debug!("IDLE response: {:?}", response);
363                // Done with IDLE, return session to normal mode
364                let session = idle.done().await?;
365                let wait_result = match response {
366                    IdleResponse::NewData(_) => IdleWaitResult::NewMail,
367                    IdleResponse::Timeout => IdleWaitResult::Timeout,
368                    IdleResponse::ManualInterrupt => IdleWaitResult::Interrupted,
369                };
370                Ok((wait_result, session))
371            }
372            Ok(Err(e)) => {
373                // Try to clean up IDLE state
374                let _ = idle.done().await;
375                Err(anyhow!("IDLE error: {}", e))
376            }
377            Err(_) => {
378                // Timeout - RFC 2177 recommends restarting IDLE every 29 minutes
379                debug!("IDLE timeout reached, will re-establish");
380                let session = idle.done().await?;
381                Ok((IdleWaitResult::Timeout, session))
382            }
383        }
384    }
385
386    /// Main IDLE-based listen loop with automatic reconnection
387    async fn listen_with_idle(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
388        let mut backoff = Duration::from_secs(1);
389        let max_backoff = Duration::from_secs(60);
390
391        loop {
392            match self.run_idle_session(&tx).await {
393                Ok(()) => {
394                    // Clean exit (channel closed)
395                    return Ok(());
396                }
397                Err(e) => {
398                    error!(
399                        "IMAP session error: {}. Reconnecting in {:?}...",
400                        e, backoff
401                    );
402                    sleep(backoff).await;
403                    // Exponential backoff with cap
404                    backoff = std::cmp::min(backoff * 2, max_backoff);
405                }
406            }
407        }
408    }
409
410    /// Run a single IDLE session until error or clean shutdown
411    async fn run_idle_session(&self, tx: &mpsc::Sender<ChannelMessage>) -> Result<()> {
412        // Connect and authenticate
413        let mut session = self.connect_imap().await?;
414
415        // Select the mailbox
416        session.select(&self.config.imap_folder).await?;
417        info!(
418            "Email IDLE listening on {} (instant push enabled)",
419            self.config.imap_folder
420        );
421
422        // Check for existing unseen messages first
423        self.process_unseen(&mut session, tx).await?;
424
425        loop {
426            // Enter IDLE and wait for changes (consumes session, returns it via result)
427            match self.wait_for_changes(session).await {
428                Ok((IdleWaitResult::NewMail, returned_session)) => {
429                    debug!("New mail notification received");
430                    session = returned_session;
431                    self.process_unseen(&mut session, tx).await?;
432                }
433                Ok((IdleWaitResult::Timeout, returned_session)) => {
434                    // Re-check for mail after IDLE timeout (defensive)
435                    session = returned_session;
436                    self.process_unseen(&mut session, tx).await?;
437                }
438                Ok((IdleWaitResult::Interrupted, _)) => {
439                    info!("IDLE interrupted, exiting");
440                    return Ok(());
441                }
442                Err(e) => {
443                    // Connection likely broken, need to reconnect
444                    return Err(e);
445                }
446            }
447        }
448    }
449
450    /// Fetch unseen messages and send to channel
451    async fn process_unseen(
452        &self,
453        session: &mut ImapSession,
454        tx: &mpsc::Sender<ChannelMessage>,
455    ) -> Result<()> {
456        let messages = self.fetch_unseen(session).await?;
457
458        for email in messages {
459            // Check allowlist
460            if !self.is_sender_allowed(&email.sender) {
461                warn!("Blocked email from {}", email.sender);
462                continue;
463            }
464
465            let is_new = {
466                let mut seen = self.seen_messages.lock().await;
467                seen.insert(email.msg_id.clone())
468            };
469            if !is_new {
470                continue;
471            }
472
473            let msg = ChannelMessage {
474                id: email.msg_id,
475                reply_target: email.sender.clone(),
476                sender: email.sender,
477                content: email.content,
478                channel: "email".to_string(),
479                timestamp: email.timestamp,
480                thread_ts: None,
481                interruption_scope_id: None,
482                attachments: vec![],
483            };
484
485            if tx.send(msg).await.is_err() {
486                // Channel closed, exit cleanly
487                return Ok(());
488            }
489        }
490
491        Ok(())
492    }
493
494    fn create_smtp_transport(&self) -> Result<SmtpTransport> {
495        let creds = Credentials::new(self.config.username.clone(), self.config.password.clone());
496        let transport = if self.config.smtp_tls {
497            SmtpTransport::relay(&self.config.smtp_host)?
498                .port(self.config.smtp_port)
499                .credentials(creds)
500                .build()
501        } else {
502            SmtpTransport::builder_dangerous(&self.config.smtp_host)
503                .port(self.config.smtp_port)
504                .credentials(creds)
505                .build()
506        };
507        Ok(transport)
508    }
509}
510
511/// Internal struct for parsed email data
512struct ParsedEmail {
513    _uid: u32,
514    msg_id: String,
515    sender: String,
516    content: String,
517    timestamp: u64,
518}
519
520/// Result from waiting on IDLE
521enum IdleWaitResult {
522    NewMail,
523    Timeout,
524    Interrupted,
525}
526
527#[async_trait]
528impl Channel for EmailChannel {
529    fn name(&self) -> &str {
530        "email"
531    }
532
533    async fn send(&self, message: &SendMessage) -> Result<()> {
534        // Use explicit subject if provided, otherwise fall back to legacy parsing or default
535        let default_subject = self.config.default_subject.as_str();
536        let (subject, body) = if let Some(ref subj) = message.subject {
537            (subj.as_str(), message.content.as_str())
538        } else if message.content.starts_with("Subject: ") {
539            if let Some(pos) = message.content.find('\n') {
540                (&message.content[9..pos], message.content[pos + 1..].trim())
541            } else {
542                (default_subject, message.content.as_str())
543            }
544        } else {
545            (default_subject, message.content.as_str())
546        };
547
548        let email = Message::builder()
549            .from(self.config.from_address.parse()?)
550            .to(message.recipient.parse()?)
551            .subject(subject)
552            .singlepart(SinglePart::plain(body.to_string()))?;
553
554        let transport = self.create_smtp_transport()?;
555        transport.send(&email)?;
556        info!("Email sent to {}", message.recipient);
557        Ok(())
558    }
559
560    async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
561        info!(
562            "Starting email channel with IDLE support on {}",
563            self.config.imap_folder
564        );
565        self.listen_with_idle(tx).await
566    }
567
568    async fn health_check(&self) -> bool {
569        // Fully async health check - attempt IMAP connection
570        match timeout(Duration::from_secs(10), self.connect_imap()).await {
571            Ok(Ok(mut session)) => {
572                // Try to logout cleanly
573                let _ = session.logout().await;
574                true
575            }
576            Ok(Err(e)) => {
577                debug!("Health check failed: {}", e);
578                false
579            }
580            Err(_) => {
581                debug!("Health check timed out");
582                false
583            }
584        }
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn default_smtp_port_uses_tls_port() {
594        assert_eq!(default_smtp_port(), 465);
595    }
596
597    #[test]
598    fn email_config_default_uses_tls_smtp_defaults() {
599        let config = EmailConfig::default();
600        assert_eq!(config.smtp_port, 465);
601        assert!(config.smtp_tls);
602    }
603
604    #[test]
605    fn default_idle_timeout_is_29_minutes() {
606        assert_eq!(default_idle_timeout(), 1740);
607    }
608
609    #[test]
610    fn max_fetch_batch_bounds_chunk_size() {
611        let cap = EmailChannel::MAX_FETCH_BATCH;
612        assert_eq!(cap, 10);
613
614        // Under cap: single chunk
615        let uids: Vec<u32> = (1..=3).collect();
616        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
617        assert_eq!(chunks.len(), 1);
618        assert_eq!(chunks[0].len(), 3);
619
620        // Exactly at cap: single chunk
621        let uids: Vec<u32> = (1..=10).collect();
622        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
623        assert_eq!(chunks.len(), 1);
624        assert_eq!(chunks[0].len(), 10);
625
626        // Over cap: two chunks
627        let uids: Vec<u32> = (1..=15).collect();
628        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
629        assert_eq!(chunks.len(), 2);
630        assert_eq!(chunks[0].len(), 10);
631        assert_eq!(chunks[1].len(), 5);
632    }
633
634    #[tokio::test]
635    async fn seen_messages_starts_empty() {
636        let channel = EmailChannel::new(EmailConfig::default());
637        let seen = channel.seen_messages.lock().await;
638        assert!(seen.is_empty());
639    }
640
641    #[tokio::test]
642    async fn seen_messages_tracks_unique_ids() {
643        let channel = EmailChannel::new(EmailConfig::default());
644        let mut seen = channel.seen_messages.lock().await;
645
646        assert!(seen.insert("first-id".to_string()));
647        assert!(!seen.insert("first-id".to_string()));
648        assert!(seen.insert("second-id".to_string()));
649        assert_eq!(seen.len(), 2);
650    }
651
652    // EmailConfig tests
653
654    #[test]
655    fn email_config_default() {
656        let config = EmailConfig::default();
657        assert_eq!(config.imap_host, "");
658        assert_eq!(config.imap_port, 993);
659        assert_eq!(config.imap_folder, "INBOX");
660        assert_eq!(config.smtp_host, "");
661        assert_eq!(config.smtp_port, 465);
662        assert!(config.smtp_tls);
663        assert_eq!(config.username, "");
664        assert_eq!(config.password, "");
665        assert_eq!(config.from_address, "");
666        assert_eq!(config.idle_timeout_secs, 1740);
667        assert!(config.allowed_senders.is_empty());
668    }
669
670    #[test]
671    fn email_config_custom() {
672        let config = EmailConfig {
673            imap_host: "imap.example.com".to_string(),
674            imap_port: 993,
675            imap_folder: "Archive".to_string(),
676            smtp_host: "smtp.example.com".to_string(),
677            smtp_port: 465,
678            smtp_tls: true,
679            username: "user@example.com".to_string(),
680            password: "pass123".to_string(),
681            from_address: "bot@example.com".to_string(),
682            idle_timeout_secs: 1200,
683            allowed_senders: vec!["allowed@example.com".to_string()],
684            default_subject: "Custom Subject".to_string(),
685        };
686        assert_eq!(config.imap_host, "imap.example.com");
687        assert_eq!(config.imap_folder, "Archive");
688        assert_eq!(config.idle_timeout_secs, 1200);
689        assert_eq!(config.default_subject, "Custom Subject");
690    }
691
692    #[test]
693    fn email_config_clone() {
694        let config = EmailConfig {
695            imap_host: "imap.test.com".to_string(),
696            imap_port: 993,
697            imap_folder: "INBOX".to_string(),
698            smtp_host: "smtp.test.com".to_string(),
699            smtp_port: 587,
700            smtp_tls: true,
701            username: "user@test.com".to_string(),
702            password: "secret".to_string(),
703            from_address: "bot@test.com".to_string(),
704            idle_timeout_secs: 1740,
705            allowed_senders: vec!["*".to_string()],
706            default_subject: "Test Subject".to_string(),
707        };
708        let cloned = config.clone();
709        assert_eq!(cloned.imap_host, config.imap_host);
710        assert_eq!(cloned.smtp_port, config.smtp_port);
711        assert_eq!(cloned.allowed_senders, config.allowed_senders);
712        assert_eq!(cloned.default_subject, config.default_subject);
713    }
714
715    // EmailChannel tests
716
717    #[tokio::test]
718    async fn email_channel_new() {
719        let config = EmailConfig::default();
720        let channel = EmailChannel::new(config.clone());
721        assert_eq!(channel.config.imap_host, config.imap_host);
722
723        let seen_guard = channel.seen_messages.lock().await;
724        assert_eq!(seen_guard.len(), 0);
725    }
726
727    #[test]
728    fn email_channel_name() {
729        let channel = EmailChannel::new(EmailConfig::default());
730        assert_eq!(channel.name(), "email");
731    }
732
733    // is_sender_allowed tests
734
735    #[test]
736    fn is_sender_allowed_empty_list_denies_all() {
737        let config = EmailConfig {
738            allowed_senders: vec![],
739            ..Default::default()
740        };
741        let channel = EmailChannel::new(config);
742        assert!(!channel.is_sender_allowed("anyone@example.com"));
743        assert!(!channel.is_sender_allowed("user@test.com"));
744    }
745
746    #[test]
747    fn is_sender_allowed_wildcard_allows_all() {
748        let config = EmailConfig {
749            allowed_senders: vec!["*".to_string()],
750            ..Default::default()
751        };
752        let channel = EmailChannel::new(config);
753        assert!(channel.is_sender_allowed("anyone@example.com"));
754        assert!(channel.is_sender_allowed("user@test.com"));
755        assert!(channel.is_sender_allowed("random@domain.org"));
756    }
757
758    #[test]
759    fn is_sender_allowed_specific_email() {
760        let config = EmailConfig {
761            allowed_senders: vec!["allowed@example.com".to_string()],
762            ..Default::default()
763        };
764        let channel = EmailChannel::new(config);
765        assert!(channel.is_sender_allowed("allowed@example.com"));
766        assert!(!channel.is_sender_allowed("other@example.com"));
767        assert!(!channel.is_sender_allowed("allowed@other.com"));
768    }
769
770    #[test]
771    fn is_sender_allowed_domain_with_at_prefix() {
772        let config = EmailConfig {
773            allowed_senders: vec!["@example.com".to_string()],
774            ..Default::default()
775        };
776        let channel = EmailChannel::new(config);
777        assert!(channel.is_sender_allowed("user@example.com"));
778        assert!(channel.is_sender_allowed("admin@example.com"));
779        assert!(!channel.is_sender_allowed("user@other.com"));
780    }
781
782    #[test]
783    fn is_sender_allowed_domain_without_at_prefix() {
784        let config = EmailConfig {
785            allowed_senders: vec!["example.com".to_string()],
786            ..Default::default()
787        };
788        let channel = EmailChannel::new(config);
789        assert!(channel.is_sender_allowed("user@example.com"));
790        assert!(channel.is_sender_allowed("admin@example.com"));
791        assert!(!channel.is_sender_allowed("user@other.com"));
792    }
793
794    #[test]
795    fn is_sender_allowed_case_insensitive() {
796        let config = EmailConfig {
797            allowed_senders: vec!["Allowed@Example.COM".to_string()],
798            ..Default::default()
799        };
800        let channel = EmailChannel::new(config);
801        assert!(channel.is_sender_allowed("allowed@example.com"));
802        assert!(channel.is_sender_allowed("ALLOWED@EXAMPLE.COM"));
803        assert!(channel.is_sender_allowed("AlLoWeD@eXaMpLe.cOm"));
804    }
805
806    #[test]
807    fn is_sender_allowed_multiple_senders() {
808        let config = EmailConfig {
809            allowed_senders: vec![
810                "user1@example.com".to_string(),
811                "user2@test.com".to_string(),
812                "@allowed.com".to_string(),
813            ],
814            ..Default::default()
815        };
816        let channel = EmailChannel::new(config);
817        assert!(channel.is_sender_allowed("user1@example.com"));
818        assert!(channel.is_sender_allowed("user2@test.com"));
819        assert!(channel.is_sender_allowed("anyone@allowed.com"));
820        assert!(!channel.is_sender_allowed("user3@example.com"));
821    }
822
823    #[test]
824    fn is_sender_allowed_wildcard_with_specific() {
825        let config = EmailConfig {
826            allowed_senders: vec!["*".to_string(), "specific@example.com".to_string()],
827            ..Default::default()
828        };
829        let channel = EmailChannel::new(config);
830        assert!(channel.is_sender_allowed("anyone@example.com"));
831        assert!(channel.is_sender_allowed("specific@example.com"));
832    }
833
834    #[test]
835    fn is_sender_allowed_empty_sender() {
836        let config = EmailConfig {
837            allowed_senders: vec!["@example.com".to_string()],
838            ..Default::default()
839        };
840        let channel = EmailChannel::new(config);
841        assert!(!channel.is_sender_allowed(""));
842        // "@example.com" ends with "@example.com" so it's allowed
843        assert!(channel.is_sender_allowed("@example.com"));
844    }
845
846    // strip_html tests
847
848    #[test]
849    fn strip_html_basic() {
850        assert_eq!(EmailChannel::strip_html("<p>Hello</p>"), "Hello");
851        assert_eq!(EmailChannel::strip_html("<div>World</div>"), "World");
852    }
853
854    #[test]
855    fn strip_html_nested_tags() {
856        assert_eq!(
857            EmailChannel::strip_html("<div><p>Hello <strong>World</strong></p></div>"),
858            "Hello World"
859        );
860    }
861
862    #[test]
863    fn strip_html_multiple_lines() {
864        let html = "<div>\n  <p>Line 1</p>\n  <p>Line 2</p>\n</div>";
865        assert_eq!(EmailChannel::strip_html(html), "Line 1 Line 2");
866    }
867
868    #[test]
869    fn strip_html_preserves_text() {
870        assert_eq!(EmailChannel::strip_html("No tags here"), "No tags here");
871        assert_eq!(EmailChannel::strip_html(""), "");
872    }
873
874    #[test]
875    fn strip_html_handles_malformed() {
876        assert_eq!(EmailChannel::strip_html("<p>Unclosed"), "Unclosed");
877        // The function removes everything between < and >, so "Text>with>brackets" becomes "Textwithbrackets"
878        assert_eq!(
879            EmailChannel::strip_html("Text>with>brackets"),
880            "Textwithbrackets"
881        );
882    }
883
884    #[test]
885    fn strip_html_self_closing_tags() {
886        // Self-closing tags are removed but don't add spaces
887        assert_eq!(EmailChannel::strip_html("Hello<br/>World"), "HelloWorld");
888        assert_eq!(EmailChannel::strip_html("Text<hr/>More"), "TextMore");
889    }
890
891    #[test]
892    fn strip_html_attributes_preserved() {
893        assert_eq!(
894            EmailChannel::strip_html("<a href=\"http://example.com\">Link</a>"),
895            "Link"
896        );
897    }
898
899    #[test]
900    fn strip_html_multiple_spaces_collapsed() {
901        assert_eq!(
902            EmailChannel::strip_html("<p>Word</p>  <p>Word</p>"),
903            "Word Word"
904        );
905    }
906
907    #[test]
908    fn strip_html_special_characters() {
909        assert_eq!(
910            EmailChannel::strip_html("<span>&lt;tag&gt;</span>"),
911            "&lt;tag&gt;"
912        );
913    }
914
915    // Default function tests
916
917    #[test]
918    fn default_imap_port_returns_993() {
919        assert_eq!(default_imap_port(), 993);
920    }
921
922    #[test]
923    fn default_smtp_port_returns_465() {
924        assert_eq!(default_smtp_port(), 465);
925    }
926
927    #[test]
928    fn default_imap_folder_returns_inbox() {
929        assert_eq!(default_imap_folder(), "INBOX");
930    }
931
932    #[test]
933    fn default_true_returns_true() {
934        assert!(default_true());
935    }
936
937    // EmailConfig serialization tests
938
939    #[test]
940    fn email_config_serialize_deserialize() {
941        let config = EmailConfig {
942            imap_host: "imap.example.com".to_string(),
943            imap_port: 993,
944            imap_folder: "INBOX".to_string(),
945            smtp_host: "smtp.example.com".to_string(),
946            smtp_port: 587,
947            smtp_tls: true,
948            username: "user@example.com".to_string(),
949            password: "password123".to_string(),
950            from_address: "bot@example.com".to_string(),
951            idle_timeout_secs: 1740,
952            allowed_senders: vec!["allowed@example.com".to_string()],
953            default_subject: "Serialization Test".to_string(),
954        };
955
956        let json = serde_json::to_string(&config).unwrap();
957        let deserialized: EmailConfig = serde_json::from_str(&json).unwrap();
958
959        assert_eq!(deserialized.imap_host, config.imap_host);
960        assert_eq!(deserialized.smtp_port, config.smtp_port);
961        assert_eq!(deserialized.allowed_senders, config.allowed_senders);
962        assert_eq!(deserialized.default_subject, config.default_subject);
963    }
964
965    #[test]
966    fn email_config_deserialize_with_defaults() {
967        let json = r#"{
968            "imap_host": "imap.test.com",
969            "smtp_host": "smtp.test.com",
970            "username": "user",
971            "password": "pass",
972            "from_address": "bot@test.com"
973        }"#;
974
975        let config: EmailConfig = serde_json::from_str(json).unwrap();
976        assert_eq!(config.imap_port, 993); // default
977        assert_eq!(config.smtp_port, 465); // default
978        assert!(config.smtp_tls); // default
979        assert_eq!(config.idle_timeout_secs, 1740); // default
980        assert_eq!(config.default_subject, "Construct Message"); // default
981    }
982
983    #[test]
984    fn idle_timeout_deserializes_explicit_value() {
985        let json = r#"{
986            "imap_host": "imap.test.com",
987            "smtp_host": "smtp.test.com",
988            "username": "user",
989            "password": "pass",
990            "from_address": "bot@test.com",
991            "idle_timeout_secs": 900
992        }"#;
993        let config: EmailConfig = serde_json::from_str(json).unwrap();
994        assert_eq!(config.idle_timeout_secs, 900);
995    }
996
997    #[test]
998    fn idle_timeout_deserializes_legacy_poll_interval_alias() {
999        let json = r#"{
1000            "imap_host": "imap.test.com",
1001            "smtp_host": "smtp.test.com",
1002            "username": "user",
1003            "password": "pass",
1004            "from_address": "bot@test.com",
1005            "poll_interval_secs": 120
1006        }"#;
1007        let config: EmailConfig = serde_json::from_str(json).unwrap();
1008        assert_eq!(config.idle_timeout_secs, 120);
1009    }
1010
1011    #[test]
1012    fn idle_timeout_propagates_to_channel() {
1013        let config = EmailConfig {
1014            idle_timeout_secs: 600,
1015            ..Default::default()
1016        };
1017        let channel = EmailChannel::new(config);
1018        assert_eq!(channel.config.idle_timeout_secs, 600);
1019    }
1020
1021    #[test]
1022    fn email_config_debug_output() {
1023        let config = EmailConfig {
1024            imap_host: "imap.debug.com".to_string(),
1025            ..Default::default()
1026        };
1027        let debug_str = format!("{:?}", config);
1028        assert!(debug_str.contains("imap.debug.com"));
1029    }
1030}