Skip to main content

zeroclaw_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;
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::header::ContentType;
18use lettre::message::{Attachment, MultiPart, SinglePart};
19use lettre::transport::smtp::authentication::Credentials;
20use lettre::{Message, SmtpTransport, Transport};
21use mail_parser::{MessageParser, MimeHeaders};
22use pulldown_cmark::{Options, Parser, html};
23use rustls::{ClientConfig, RootCertStore};
24use rustls_pki_types::DnsName;
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 uuid::Uuid;
34
35use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
36
37pub use zeroclaw_config::scattered_types::EmailConfig;
38
39type ImapSession = Session<TlsStream<TcpStream>>;
40
41/// Email channel — IMAP IDLE for instant push notifications, SMTP for outbound.
42///
43/// Inbound sender authorization lives in `peer_groups` in V3; this channel
44/// resolves the authorized senders at message-time via [`Self::peer_resolver`]
45/// rather than reading a per-channel `allowed_senders` field (it no longer
46/// exists on `EmailConfig`).
47pub struct EmailChannel {
48    pub config: EmailConfig,
49    /// The alias key under `[channels.email.<alias>]` this handle is
50    /// bound to. Used to scope peer-group writes and resolver lookups.
51    pub alias: String,
52    /// Resolves inbound external peers from canonical state at message-time.
53    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
54    pub peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
55    seen_messages: Arc<Mutex<HashSet<String>>>,
56}
57
58impl EmailChannel {
59    pub fn new(
60        config: EmailConfig,
61        alias: impl Into<String>,
62        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
63    ) -> Self {
64        Self {
65            config,
66            alias: alias.into(),
67            peer_resolver,
68            seen_messages: Arc::new(Mutex::new(HashSet::new())),
69        }
70    }
71
72    /// Check if a sender email is in the allowlist (peer group).
73    ///
74    /// Email allowlist entries support three syntaxes — preserved from
75    /// the legacy `EmailConfig::allowed_senders` semantics:
76    /// - `*`                wildcard, allow anyone.
77    /// - `user@host`        full address, case-insensitive.
78    /// - `@host` / `host`   domain match, case-insensitive.
79    pub fn is_sender_allowed(&self, email: &str) -> bool {
80        let peers = (self.peer_resolver)();
81        Self::is_email_sender_allowed(&peers, email)
82    }
83
84    /// Pure, testable predicate that applies the email-allowlist match
85    /// semantics against an already-resolved peer list.
86    fn is_email_sender_allowed(peers: &[String], email: &str) -> bool {
87        if peers.is_empty() {
88            return false; // Empty = deny all
89        }
90        if peers.iter().any(|a| a == "*") {
91            return true; // Wildcard = allow all
92        }
93        let email_lower = email.to_lowercase();
94        peers.iter().any(|allowed| {
95            if allowed.starts_with('@') {
96                // Domain match with @ prefix: "@example.com"
97                email_lower.ends_with(&allowed.to_lowercase())
98            } else if allowed.contains('@') {
99                // Full email address match
100                allowed.eq_ignore_ascii_case(email)
101            } else {
102                // Domain match without @ prefix: "example.com"
103                email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
104            }
105        })
106    }
107
108    /// Strip HTML tags from content (basic)
109    pub fn strip_html(html: &str) -> String {
110        let mut result = String::new();
111        let mut in_tag = false;
112        for ch in html.chars() {
113            match ch {
114                '<' => in_tag = true,
115                '>' => in_tag = false,
116                _ if !in_tag => result.push(ch),
117                _ => {}
118            }
119        }
120        let mut normalized = String::with_capacity(result.len());
121        for word in result.split_whitespace() {
122            if !normalized.is_empty() {
123                normalized.push(' ');
124            }
125            normalized.push_str(word);
126        }
127        normalized
128    }
129
130    /// Extract the sender address from a parsed email
131    fn extract_sender(parsed: &mail_parser::Message) -> String {
132        parsed
133            .from()
134            .and_then(|addr| addr.first())
135            .and_then(|a| a.address())
136            .map(|s| s.to_string())
137            .unwrap_or_else(|| "unknown".into())
138    }
139
140    /// Extract readable text from a parsed email
141    fn extract_text(parsed: &mail_parser::Message) -> String {
142        if let Some(text) = parsed.body_text(0) {
143            return text.to_string();
144        }
145        if let Some(html) = parsed.body_html(0) {
146            return Self::strip_html(html.as_ref());
147        }
148        for part in parsed.attachments() {
149            let part: &mail_parser::MessagePart = part;
150            if let Some(ct) = MimeHeaders::content_type(part)
151                && ct.ctype() == "text"
152                && let Ok(text) = std::str::from_utf8(part.contents())
153            {
154                let name = MimeHeaders::attachment_name(part).unwrap_or("file");
155                return format!("[Attachment: {}]\n{}", name, text);
156            }
157        }
158        "(no readable content)".to_string()
159    }
160
161    /// Extract binary attachments from a parsed email as MediaAttachment entries.
162    fn extract_attachments(
163        &self,
164        parsed: &mail_parser::Message,
165    ) -> Vec<zeroclaw_api::media::MediaAttachment> {
166        let mut attachments = Vec::new();
167        let mut total_size = 0;
168
169        for part in parsed.attachments() {
170            let part: &mail_parser::MessagePart = part;
171            let ct = MimeHeaders::content_type(part);
172            let mime_str =
173                ct.map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("octet-stream")));
174
175            // Skip text parts — already handled by extract_text()
176            if let Some(ref m) = mime_str
177                && m.starts_with("text/")
178            {
179                continue;
180            }
181
182            let data = part.contents().to_vec();
183            if data.is_empty() {
184                continue;
185            }
186
187            // Check size limit
188            total_size += data.len();
189            if total_size > self.config.max_attachment_bytes {
190                ::zeroclaw_log::record!(
191                    WARN,
192                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
193                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
194                    &format!(
195                        "Attachment size limit exceeded ({} bytes), dropping remaining attachments",
196                        self.config.max_attachment_bytes
197                    )
198                );
199                break;
200            }
201
202            let file_name = MimeHeaders::attachment_name(part)
203                .unwrap_or("attachment")
204                .to_string();
205
206            attachments.push(zeroclaw_api::media::MediaAttachment {
207                file_name,
208                data,
209                mime_type: mime_str,
210            });
211        }
212        attachments
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        ::zeroclaw_log::record!(
219            DEBUG,
220            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
221            &format!("Connecting to IMAP server at {}", addr)
222        );
223
224        // Connect TCP
225        let tcp = TcpStream::connect(&addr).await?;
226
227        // Establish TLS using rustls
228        let certs = RootCertStore {
229            roots: webpki_roots::TLS_SERVER_ROOTS.into(),
230        };
231        let config = ClientConfig::builder()
232            .with_root_certificates(certs)
233            .with_no_client_auth();
234        let tls_stream: TlsConnector = Arc::new(config).into();
235        let sni: DnsName = self.config.imap_host.clone().try_into()?;
236        let stream = tls_stream.connect(sni.into(), tcp).await?;
237
238        // Create IMAP client
239        let client = async_imap::Client::new(stream);
240
241        // Login
242        let session = client
243            .login(&self.config.username, &self.config.password)
244            .await
245            .map_err(|(e, _)| {
246                ::zeroclaw_log::record!(
247                    ERROR,
248                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
249                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
250                        .with_attrs(::serde_json::json!({
251                            "phase": "imap_login",
252                            "error": format!("{}", e),
253                        })),
254                    "email: IMAP login failed"
255                );
256                anyhow::Error::msg(format!("IMAP login failed: {}", e))
257            })?;
258
259        ::zeroclaw_log::record!(
260            DEBUG,
261            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
262            "IMAP login successful"
263        );
264        Ok(session)
265    }
266
267    /// Maximum number of messages fetched per IMAP round-trip.
268    /// Bounds peak memory when the mailbox has a large unseen backlog.
269    const MAX_FETCH_BATCH: usize = 10;
270
271    /// Fetch and process unseen messages from the selected mailbox.
272    ///
273    /// UIDs are fetched in chunks of [`Self::MAX_FETCH_BATCH`] to bound the
274    /// number of message bodies (and any audio attachments) held in memory at
275    /// once. Each chunk is marked `\Seen` immediately after fetch so that
276    /// successfully retrieved messages are not re-fetched if a later chunk fails.
277    async fn fetch_unseen(&self, session: &mut ImapSession) -> Result<Vec<ParsedEmail>> {
278        // Search for unseen messages
279        let uids = session.uid_search("UNSEEN").await?;
280        if uids.is_empty() {
281            return Ok(Vec::new());
282        }
283
284        ::zeroclaw_log::record!(
285            DEBUG,
286            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
287            &format!("Found {} unseen messages", uids.len())
288        );
289
290        let uid_list: Vec<u32> = uids.into_iter().collect();
291        let mut results = Vec::new();
292
293        for chunk in uid_list.chunks(Self::MAX_FETCH_BATCH) {
294            let uid_set: String = chunk
295                .iter()
296                .map(|u| u.to_string())
297                .collect::<Vec<_>>()
298                .join(",");
299
300            // Fetch message bodies for this chunk
301            let messages = session.uid_fetch(&uid_set, "RFC822").await?;
302            let messages: Vec<Fetch> = messages.try_collect().await?;
303
304            for msg in messages {
305                let uid = msg.uid.unwrap_or(0);
306                if let Some(body) = msg.body()
307                    && let Some(parsed) = MessageParser::default().parse(body)
308                {
309                    let sender = Self::extract_sender(&parsed);
310                    let subject = parsed.subject().unwrap_or("(no subject)").to_string();
311                    let body_text = Self::extract_text(&parsed);
312                    let content = format!("Subject: {}\n\n{}", subject, body_text);
313                    let msg_id = parsed
314                        .message_id()
315                        .map(|s| s.to_string())
316                        .unwrap_or_else(|| format!("gen-{}", Uuid::new_v4()));
317
318                    #[allow(clippy::cast_sign_loss)]
319                    let ts = parsed
320                        .date()
321                        .map(|d| {
322                            let naive = chrono::NaiveDate::from_ymd_opt(
323                                d.year as i32,
324                                u32::from(d.month),
325                                u32::from(d.day),
326                            )
327                            .and_then(|date| {
328                                date.and_hms_opt(
329                                    u32::from(d.hour),
330                                    u32::from(d.minute),
331                                    u32::from(d.second),
332                                )
333                            });
334                            naive.map_or(0, |n| n.and_utc().timestamp() as u64)
335                        })
336                        .unwrap_or_else(|| {
337                            SystemTime::now()
338                                .duration_since(UNIX_EPOCH)
339                                .map(|d| d.as_secs())
340                                .unwrap_or(0)
341                        });
342
343                    let attachments = self.extract_attachments(&parsed);
344
345                    results.push(ParsedEmail {
346                        _uid: uid,
347                        msg_id,
348                        sender,
349                        subject,
350                        content,
351                        timestamp: ts,
352                        attachments,
353                    });
354                }
355            }
356
357            // Mark this chunk as seen before fetching the next
358            let _ = session
359                .uid_store(&uid_set, "+FLAGS (\\Seen)")
360                .await?
361                .try_collect::<Vec<_>>()
362                .await;
363        }
364
365        Ok(results)
366    }
367
368    /// Run the IDLE loop, returning when a new message arrives or timeout
369    /// Note: IDLE consumes the session and returns it via done()
370    async fn wait_for_changes(
371        &self,
372        session: ImapSession,
373    ) -> Result<(IdleWaitResult, ImapSession)> {
374        let idle_timeout = Duration::from_secs(self.config.idle_timeout_secs);
375
376        // Start IDLE mode - this consumes the session
377        let mut idle = session.idle();
378        idle.init().await?;
379
380        ::zeroclaw_log::record!(
381            DEBUG,
382            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
383            "Entering IMAP IDLE mode"
384        );
385
386        // wait() returns (future, stop_source) - we only need the future
387        let (wait_future, _stop_source) = idle.wait();
388
389        // Wait for server notification or timeout
390        let result = timeout(idle_timeout, wait_future).await;
391
392        match result {
393            Ok(Ok(response)) => {
394                ::zeroclaw_log::record!(
395                    DEBUG,
396                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
397                    &format!("IDLE response: {:?}", response)
398                );
399                // Done with IDLE, return session to normal mode
400                let session = idle.done().await?;
401                let wait_result = match response {
402                    IdleResponse::NewData(_) => IdleWaitResult::NewMail,
403                    IdleResponse::Timeout => IdleWaitResult::Timeout,
404                    IdleResponse::ManualInterrupt => IdleWaitResult::Interrupted,
405                };
406                Ok((wait_result, session))
407            }
408            Ok(Err(e)) => {
409                // Try to clean up IDLE state
410                let _ = idle.done().await;
411                ::zeroclaw_log::record!(
412                    ERROR,
413                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
414                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
415                        .with_attrs(::serde_json::json!({
416                            "phase": "idle_wait",
417                            "error": format!("{}", e),
418                        })),
419                    "email: IDLE error"
420                );
421                Err(anyhow::Error::msg(format!("IDLE error: {}", e)))
422            }
423            Err(_) => {
424                // Timeout - RFC 2177 recommends restarting IDLE every 29 minutes
425                ::zeroclaw_log::record!(
426                    DEBUG,
427                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
428                    "IDLE timeout reached, will re-establish"
429                );
430                let session = idle.done().await?;
431                Ok((IdleWaitResult::Timeout, session))
432            }
433        }
434    }
435
436    /// Main listen loop with automatic reconnection.
437    ///
438    /// Probes the server's CAPABILITY list after login and picks between:
439    /// - IMAP IDLE (RFC 2177) for instant push when the server advertises it.
440    /// - Periodic polling when the server does not support IDLE (e.g. seznam.cz).
441    async fn listen_with_reconnect(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
442        let mut backoff = Duration::from_secs(1);
443        let max_backoff = Duration::from_secs(60);
444
445        loop {
446            match self.run_session(&tx).await {
447                Ok(()) => {
448                    // Clean exit (channel closed)
449                    return Ok(());
450                }
451                Err(e) => {
452                    ::zeroclaw_log::record!(
453                        ERROR,
454                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
455                            .with_outcome(::zeroclaw_log::EventOutcome::Failure),
456                        &format!(
457                            "IMAP session error: {}. Reconnecting in {:?}...",
458                            e, backoff
459                        )
460                    );
461                    sleep(backoff).await;
462                    // Exponential backoff with cap
463                    backoff = std::cmp::min(backoff * 2, max_backoff);
464                }
465            }
466        }
467    }
468
469    /// Run a single IMAP session. Probes server capabilities and dispatches
470    /// to the IDLE or polling inner loop.
471    async fn run_session(&self, tx: &mpsc::Sender<ChannelMessage>) -> Result<()> {
472        // Connect and authenticate
473        let mut session = self.connect_imap().await?;
474
475        // Select the mailbox
476        session.select(&self.config.imap_folder).await?;
477
478        // Probe the server's post-auth capabilities to decide IDLE vs poll.
479        // RFC 3501 allows capabilities to change after authentication, so we
480        // probe after login rather than before.
481        let has_idle = {
482            let caps = session.capabilities().await?;
483            caps.has_str("IDLE")
484        };
485
486        // Drain any existing unseen messages first, regardless of mode
487        self.process_unseen(&mut session, tx).await?;
488
489        if has_idle {
490            ::zeroclaw_log::record!(
491                INFO,
492                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
493                &format!(
494                    "Email channel listening on {} (IMAP IDLE, instant push)",
495                    self.config.imap_folder
496                )
497            );
498            self.run_idle_inner(session, tx).await
499        } else {
500            let poll_interval = Duration::from_secs(self.config.poll_interval_secs);
501            ::zeroclaw_log::record!(
502                INFO,
503                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
504                &format!(
505                    "Email channel listening on {} (IMAP polling, server lacks IDLE, interval: {:?})",
506                    self.config.imap_folder, poll_interval
507                )
508            );
509            self.run_poll_inner(session, tx, poll_interval).await
510        }
511    }
512
513    /// IDLE-based wait loop. Consumes and returns the session across IDLE round trips.
514    async fn run_idle_inner(
515        &self,
516        mut session: ImapSession,
517        tx: &mpsc::Sender<ChannelMessage>,
518    ) -> Result<()> {
519        loop {
520            // Enter IDLE and wait for changes (consumes session, returns it via result)
521            match self.wait_for_changes(session).await {
522                Ok((IdleWaitResult::NewMail, returned_session)) => {
523                    ::zeroclaw_log::record!(
524                        DEBUG,
525                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
526                        "New mail notification received"
527                    );
528                    session = returned_session;
529                    self.process_unseen(&mut session, tx).await?;
530                }
531                Ok((IdleWaitResult::Timeout, returned_session)) => {
532                    // Re-check for mail after IDLE timeout (defensive)
533                    session = returned_session;
534                    self.process_unseen(&mut session, tx).await?;
535                }
536                Ok((IdleWaitResult::Interrupted, _)) => {
537                    ::zeroclaw_log::record!(
538                        INFO,
539                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
540                        "IDLE interrupted, exiting"
541                    );
542                    return Ok(());
543                }
544                Err(e) => {
545                    // Connection likely broken, need to reconnect
546                    return Err(e);
547                }
548            }
549        }
550    }
551
552    /// Polling-based wait loop. Used when the server does not advertise IDLE.
553    /// Sleeps for `poll_interval` between UNSEEN checks and sends a NOOP each
554    /// cycle to keep the connection alive and detect drops early.
555    async fn run_poll_inner(
556        &self,
557        mut session: ImapSession,
558        tx: &mpsc::Sender<ChannelMessage>,
559        poll_interval: Duration,
560    ) -> Result<()> {
561        loop {
562            sleep(poll_interval).await;
563            // NOOP both keeps the connection alive and causes the server to
564            // flush any pending EXISTS/EXPUNGE updates before we search.
565            session.noop().await?;
566            self.process_unseen(&mut session, tx).await?;
567        }
568    }
569
570    /// Fetch unseen messages and send to channel
571    async fn process_unseen(
572        &self,
573        session: &mut ImapSession,
574        tx: &mpsc::Sender<ChannelMessage>,
575    ) -> Result<()> {
576        let messages = self.fetch_unseen(session).await?;
577
578        for email in messages {
579            // Check allowlist
580            if !self.is_sender_allowed(&email.sender) {
581                ::zeroclaw_log::record!(
582                    WARN,
583                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
584                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
585                    &format!("Blocked email from {}", email.sender)
586                );
587                continue;
588            }
589
590            let is_new = {
591                let mut seen = self.seen_messages.lock().await;
592                seen.insert(email.msg_id.clone())
593            };
594            if !is_new {
595                continue;
596            }
597
598            let msg = ChannelMessage {
599                channel_alias: Some(self.alias.clone()),
600                attachments: email.attachments,
601                subject: Some(email.subject),
602                ..ChannelMessage::new(
603                    email.msg_id,
604                    email.sender.clone(),
605                    email.sender,
606                    email.content,
607                    "email",
608                    email.timestamp,
609                )
610            };
611
612            if tx.send(msg).await.is_err() {
613                // Channel closed, exit cleanly
614                return Ok(());
615            }
616        }
617
618        Ok(())
619    }
620
621    fn smtp_credentials(&self) -> Credentials {
622        let user = self
623            .config
624            .smtp_username
625            .as_deref()
626            .unwrap_or(&self.config.username)
627            .to_owned();
628        let pass = self
629            .config
630            .smtp_password
631            .as_deref()
632            .unwrap_or(&self.config.password)
633            .to_owned();
634        Credentials::new(user, pass)
635    }
636
637    fn create_smtp_transport(&self) -> Result<SmtpTransport> {
638        let creds = self.smtp_credentials();
639        let transport = if self.config.smtp_tls {
640            SmtpTransport::relay(&self.config.smtp_host)?
641                .port(self.config.smtp_port)
642                .credentials(creds)
643                .build()
644        } else {
645            SmtpTransport::builder_dangerous(&self.config.smtp_host)
646                .port(self.config.smtp_port)
647                .credentials(creds)
648                .build()
649        };
650        Ok(transport)
651    }
652}
653
654/// Internal struct for parsed email data
655struct ParsedEmail {
656    _uid: u32,
657    msg_id: String,
658    sender: String,
659    subject: String,
660    content: String,
661    timestamp: u64,
662    attachments: Vec<zeroclaw_api::media::MediaAttachment>,
663}
664
665/// Result from waiting on IDLE
666enum IdleWaitResult {
667    NewMail,
668    Timeout,
669    Interrupted,
670}
671
672impl ::zeroclaw_api::attribution::Attributable for EmailChannel {
673    fn role(&self) -> ::zeroclaw_api::attribution::Role {
674        ::zeroclaw_api::attribution::Role::Channel(::zeroclaw_api::attribution::ChannelKind::Email)
675    }
676    fn alias(&self) -> &str {
677        &self.alias
678    }
679}
680
681fn markdown_to_html(md: &str) -> String {
682    let mut options = Options::empty();
683    options.insert(Options::ENABLE_TABLES);
684    options.insert(Options::ENABLE_STRIKETHROUGH);
685    let parser = Parser::new_ext(md, options);
686    let mut html_output = String::new();
687    html::push_html(&mut html_output, parser);
688    html_output
689}
690#[async_trait]
691
692impl Channel for EmailChannel {
693    fn name(&self) -> &str {
694        "email"
695    }
696
697    async fn send(&self, message: &SendMessage) -> Result<()> {
698        // Use explicit subject if provided, otherwise fall back to legacy parsing or default
699        let default_subject = self.config.default_subject.as_str();
700        let (subject, body) = if let Some(ref subj) = message.subject {
701            (subj.as_str(), message.content.as_str())
702        } else if message.content.starts_with("Subject: ") {
703            if let Some(pos) = message.content.find('\n') {
704                (&message.content[9..pos], message.content[pos + 1..].trim())
705            } else {
706                (default_subject, message.content.as_str())
707            }
708        } else {
709            (default_subject, message.content.as_str())
710        };
711
712        let mut builder = Message::builder()
713            .from(self.config.from_address.parse()?)
714            .to(message.recipient.parse()?)
715            .subject(subject);
716        if let Some(ref reply_id) = message.in_reply_to {
717            builder = builder.in_reply_to(reply_id.clone());
718        }
719        let mut att_parts: Vec<(String, Vec<u8>, ContentType)> = Vec::new();
720        for att in &message.attachments {
721            let content_type = att
722                .mime_type
723                .as_deref()
724                .and_then(|m| ContentType::parse(m).ok())
725                .unwrap_or_else(|| {
726                    ContentType::parse("application/octet-stream").expect("hardcoded MIME type")
727                });
728            let att_data = resolve_attachment_data(&att.file_name, &att.data)?;
729            let att_name = std::path::Path::new(&att.file_name)
730                .file_name()
731                .and_then(|n| n.to_str())
732                .unwrap_or(&att.file_name)
733                .to_string();
734            att_parts.push((att_name, att_data, content_type));
735        }
736
737        let email = if self.config.html_body {
738            let alt = MultiPart::alternative()
739                .singlepart(SinglePart::plain(body.to_string()))
740                .singlepart(SinglePart::html(markdown_to_html(body)));
741            if att_parts.is_empty() {
742                builder.multipart(alt)?
743            } else {
744                let mut mixed = MultiPart::mixed().multipart(alt);
745                for (name, data, ct) in att_parts {
746                    mixed = mixed.singlepart(Attachment::new(name).body(data, ct));
747                }
748                builder.multipart(mixed)?
749            }
750        } else {
751            let plain = SinglePart::plain(body.to_string());
752            if att_parts.is_empty() {
753                builder.singlepart(plain)?
754            } else {
755                let mut mixed = MultiPart::mixed().singlepart(plain);
756                for (name, data, ct) in att_parts {
757                    mixed = mixed.singlepart(Attachment::new(name).body(data, ct));
758                }
759                builder.multipart(mixed)?
760            }
761        };
762
763        let transport = self.create_smtp_transport()?;
764        transport.send(&email)?;
765        ::zeroclaw_log::record!(
766            INFO,
767            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
768            &format!(
769                "Email sent to {} ({} attachments)",
770                message.recipient,
771                message.attachments.len()
772            )
773        );
774        Ok(())
775    }
776
777    async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
778        ::zeroclaw_log::record!(
779            INFO,
780            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
781            &format!(
782                "Starting email channel on {} (IDLE preferred, polling fallback)",
783                self.config.imap_folder
784            )
785        );
786        self.listen_with_reconnect(tx).await
787    }
788
789    async fn health_check(&self) -> bool {
790        // Fully async health check - attempt IMAP connection
791        match timeout(Duration::from_secs(10), self.connect_imap()).await {
792            Ok(Ok(mut session)) => {
793                // Try to logout cleanly
794                let _ = session.logout().await;
795                true
796            }
797            Ok(Err(e)) => {
798                ::zeroclaw_log::record!(
799                    DEBUG,
800                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
801                    &format!("Health check failed: {}", e)
802                );
803                false
804            }
805            Err(_) => {
806                ::zeroclaw_log::record!(
807                    DEBUG,
808                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
809                    "Health check timed out"
810                );
811                false
812            }
813        }
814    }
815}
816
817/// Resolve the byte content of an attachment for sending.
818///
819/// # Trust boundary
820///
821/// `file_name` is treated as a file-system path **only** when `data` is empty.
822/// This fallback exists exclusively for internally constructed
823/// [`MediaAttachment`](zeroclaw_api::media::MediaAttachment) values whose
824/// bytes were intentionally omitted (e.g. created via
825/// [`MediaAttachment::from_file`](zeroclaw_api::media::MediaAttachment::from_file)
826/// after a round-trip through serialization).  Callers that build attachments
827/// from untrusted input — user messages, HTTP request bodies, or any external
828/// data source — **must** validate or constrain `file_name` before reaching
829/// this function; no additional path sanitization is applied here.
830///
831/// Read errors are propagated rather than silently suppressed.
832fn resolve_attachment_data(file_name: &str, data: &[u8]) -> anyhow::Result<Vec<u8>> {
833    if data.is_empty() && std::path::Path::new(file_name).exists() {
834        std::fs::read(file_name).map_err(|e| {
835            anyhow::Error::msg(format!("failed to read attachment '{}': {}", file_name, e))
836        })
837    } else {
838        Ok(data.to_vec())
839    }
840}
841
842#[cfg(test)]
843mod tests {
844    fn default_imap_port() -> u16 {
845        993
846    }
847    fn default_smtp_port() -> u16 {
848        465
849    }
850    fn default_imap_folder() -> String {
851        "INBOX".into()
852    }
853    fn default_idle_timeout() -> u64 {
854        1740
855    }
856    fn default_true() -> bool {
857        true
858    }
859    fn default_max_attachment_bytes() -> usize {
860        25 * 1024 * 1024
861    }
862    use super::*;
863
864    // -- resolve_attachment_data tests --
865
866    #[test]
867    fn resolve_attachment_data_returns_provided_bytes_when_non_empty() {
868        let data = b"hello attachment".to_vec();
869        let result = resolve_attachment_data("ignored.bin", &data).unwrap();
870        assert_eq!(result, data);
871    }
872
873    #[test]
874    fn resolve_attachment_data_falls_back_to_file_when_data_empty_and_file_exists() {
875        let dir = tempfile::tempdir().unwrap();
876        let path = dir.path().join("att.txt");
877        std::fs::write(&path, b"file contents").unwrap();
878        let result = resolve_attachment_data(path.to_str().unwrap(), &[]).unwrap();
879        assert_eq!(result, b"file contents");
880    }
881
882    #[test]
883    fn resolve_attachment_data_returns_empty_when_data_empty_and_file_absent() {
884        // file_name does not exist on disk — should return empty vec, not error.
885        // Use a temp dir to guarantee the path does not exist, rather than a
886        // hard-coded /tmp path, for portability.
887        let dir = tempfile::tempdir().unwrap();
888        let absent = dir.path().join("does-not-exist.bin");
889        let result = resolve_attachment_data(absent.to_str().unwrap(), &[]).unwrap();
890        assert!(result.is_empty());
891    }
892
893    #[test]
894    fn resolve_attachment_data_propagates_read_error_on_unreadable_file() {
895        // Create a file, then make it unreadable (Unix only).
896        #[cfg(unix)]
897        {
898            use std::os::unix::fs::PermissionsExt;
899            let dir = tempfile::tempdir().unwrap();
900            let path = dir.path().join("locked.bin");
901            std::fs::write(&path, b"secret").unwrap();
902            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
903            // Permission enforcement is not guaranteed when running as root;
904            // skip rather than produce a false failure.  Reading from
905            // /proc/self/status is Linux-specific but that is where this test
906            // is most likely to run.  On other Unix systems the check falls
907            // back to the USER env var, which is a best-effort heuristic only.
908            #[cfg(target_os = "linux")]
909            let is_root = std::fs::read_to_string("/proc/self/status")
910                .ok()
911                .and_then(|s| {
912                    s.lines()
913                        .find(|l| l.starts_with("Uid:"))
914                        .and_then(|l| l.split_whitespace().nth(1))
915                        .and_then(|uid| uid.parse::<u32>().ok())
916                })
917                .map(|uid| uid == 0)
918                .unwrap_or(false);
919            #[cfg(not(target_os = "linux"))]
920            let is_root = std::env::var("USER").map(|u| u == "root").unwrap_or(false);
921            if is_root {
922                return;
923            }
924            let result = resolve_attachment_data(path.to_str().unwrap(), &[]);
925            assert!(result.is_err());
926        }
927    }
928
929    #[test]
930    fn default_smtp_port_uses_tls_port() {
931        assert_eq!(default_smtp_port(), 465);
932    }
933
934    #[test]
935    fn email_config_default_uses_tls_smtp_defaults() {
936        let config = EmailConfig::default();
937        assert_eq!(config.smtp_port, 465);
938        assert!(config.smtp_tls);
939    }
940
941    #[test]
942    fn default_idle_timeout_is_29_minutes() {
943        assert_eq!(default_idle_timeout(), 1740);
944    }
945
946    #[test]
947    fn max_fetch_batch_bounds_chunk_size() {
948        let cap = EmailChannel::MAX_FETCH_BATCH;
949        assert_eq!(cap, 10);
950
951        // Under cap: single chunk
952        let uids: Vec<u32> = (1..=3).collect();
953        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
954        assert_eq!(chunks.len(), 1);
955        assert_eq!(chunks[0].len(), 3);
956
957        // Exactly at cap: single chunk
958        let uids: Vec<u32> = (1..=10).collect();
959        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
960        assert_eq!(chunks.len(), 1);
961        assert_eq!(chunks[0].len(), 10);
962
963        // Over cap: two chunks
964        let uids: Vec<u32> = (1..=15).collect();
965        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
966        assert_eq!(chunks.len(), 2);
967        assert_eq!(chunks[0].len(), 10);
968        assert_eq!(chunks[1].len(), 5);
969    }
970
971    #[tokio::test]
972    async fn seen_messages_starts_empty() {
973        let channel =
974            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
975        let seen = channel.seen_messages.lock().await;
976        assert!(seen.is_empty());
977    }
978
979    #[tokio::test]
980    async fn seen_messages_tracks_unique_ids() {
981        let channel =
982            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
983        let mut seen = channel.seen_messages.lock().await;
984
985        assert!(seen.insert("first-id".to_string()));
986        assert!(!seen.insert("first-id".to_string()));
987        assert!(seen.insert("second-id".to_string()));
988        assert_eq!(seen.len(), 2);
989    }
990
991    // EmailConfig tests
992
993    #[test]
994    fn email_config_default() {
995        let config = EmailConfig::default();
996        assert_eq!(config.imap_host, "");
997        assert_eq!(config.imap_port, 993);
998        assert_eq!(config.imap_folder, "INBOX");
999        assert_eq!(config.smtp_host, "");
1000        assert_eq!(config.smtp_port, 465);
1001        assert!(config.smtp_tls);
1002        assert_eq!(config.username, "");
1003        assert_eq!(config.password, "");
1004        assert_eq!(config.from_address, "");
1005        assert_eq!(config.idle_timeout_secs, 1740);
1006    }
1007
1008    // EmailChannel tests
1009    //
1010    // Inbound peer authorization lives in `peer_groups` in V3; the
1011    // channel resolves the authorized senders via a peer_resolver
1012    // closure provided at construction.
1013
1014    fn empty_resolver() -> Arc<dyn Fn() -> Vec<String> + Send + Sync> {
1015        Arc::new(Vec::new)
1016    }
1017
1018    fn resolver_from(peers: Vec<String>) -> Arc<dyn Fn() -> Vec<String> + Send + Sync> {
1019        Arc::new(move || peers.clone())
1020    }
1021
1022    #[test]
1023    fn email_config_custom() {
1024        let config = EmailConfig {
1025            enabled: true,
1026            imap_host: "imap.example.com".to_string(),
1027            imap_port: 993,
1028            imap_folder: "Archive".to_string(),
1029            smtp_host: "smtp.example.com".to_string(),
1030            smtp_port: 465,
1031            smtp_tls: true,
1032            username: "user@example.com".to_string(),
1033            password: "pass123".to_string(),
1034            smtp_username: None,
1035            smtp_password: None,
1036            from_address: "bot@example.com".to_string(),
1037            idle_timeout_secs: 1200,
1038            poll_interval_secs: 60,
1039            default_subject: "Custom Subject".to_string(),
1040            max_attachment_bytes: default_max_attachment_bytes(),
1041            html_body: true,
1042            excluded_tools: vec![],
1043        };
1044        assert_eq!(config.imap_host, "imap.example.com");
1045        assert_eq!(config.imap_folder, "Archive");
1046        assert_eq!(config.idle_timeout_secs, 1200);
1047        assert_eq!(config.default_subject, "Custom Subject");
1048    }
1049
1050    #[test]
1051    fn email_config_clone() {
1052        let config = EmailConfig {
1053            enabled: true,
1054            imap_host: "imap.test.com".to_string(),
1055            imap_port: 993,
1056            imap_folder: "INBOX".to_string(),
1057            smtp_host: "smtp.test.com".to_string(),
1058            smtp_port: 587,
1059            smtp_tls: true,
1060            username: "user@test.com".to_string(),
1061            password: "secret".to_string(),
1062            smtp_username: None,
1063            smtp_password: None,
1064            from_address: "bot@test.com".to_string(),
1065            idle_timeout_secs: 1740,
1066            poll_interval_secs: 60,
1067            default_subject: "Test Subject".to_string(),
1068            max_attachment_bytes: default_max_attachment_bytes(),
1069            html_body: true,
1070            excluded_tools: vec![],
1071        };
1072        let cloned = config.clone();
1073        assert_eq!(cloned.imap_host, config.imap_host);
1074        assert_eq!(cloned.smtp_port, config.smtp_port);
1075        assert_eq!(cloned.default_subject, config.default_subject);
1076    }
1077
1078    #[tokio::test]
1079    async fn email_channel_new() {
1080        let config = EmailConfig::default();
1081        let channel = EmailChannel::new(config.clone(), "email_test_alias", empty_resolver());
1082        assert_eq!(channel.config.imap_host, config.imap_host);
1083
1084        let seen_guard = channel.seen_messages.lock().await;
1085        assert_eq!(seen_guard.len(), 0);
1086    }
1087
1088    #[test]
1089    fn email_channel_name() {
1090        let channel =
1091            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
1092        assert_eq!(channel.name(), "email");
1093    }
1094
1095    // is_sender_allowed tests
1096
1097    #[test]
1098    fn is_sender_allowed_empty_list_denies_all() {
1099        let channel =
1100            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
1101        assert!(!channel.is_sender_allowed("anyone@example.com"));
1102        assert!(!channel.is_sender_allowed("user@test.com"));
1103    }
1104
1105    #[test]
1106    fn is_sender_allowed_wildcard_allows_all() {
1107        let channel = EmailChannel::new(
1108            EmailConfig::default(),
1109            "email_test_alias",
1110            resolver_from(vec!["*".to_string()]),
1111        );
1112        assert!(channel.is_sender_allowed("anyone@example.com"));
1113        assert!(channel.is_sender_allowed("user@test.com"));
1114        assert!(channel.is_sender_allowed("random@domain.org"));
1115    }
1116
1117    #[test]
1118    fn is_sender_allowed_specific_email() {
1119        let channel = EmailChannel::new(
1120            EmailConfig::default(),
1121            "email_test_alias",
1122            resolver_from(vec!["allowed@example.com".to_string()]),
1123        );
1124        assert!(channel.is_sender_allowed("allowed@example.com"));
1125        assert!(!channel.is_sender_allowed("other@example.com"));
1126        assert!(!channel.is_sender_allowed("allowed@other.com"));
1127    }
1128
1129    #[test]
1130    fn is_sender_allowed_domain_with_at_prefix() {
1131        let channel = EmailChannel::new(
1132            EmailConfig::default(),
1133            "email_test_alias",
1134            resolver_from(vec!["@example.com".to_string()]),
1135        );
1136        assert!(channel.is_sender_allowed("user@example.com"));
1137        assert!(channel.is_sender_allowed("admin@example.com"));
1138        assert!(!channel.is_sender_allowed("user@other.com"));
1139    }
1140
1141    #[test]
1142    fn is_sender_allowed_domain_without_at_prefix() {
1143        let channel = EmailChannel::new(
1144            EmailConfig::default(),
1145            "email_test_alias",
1146            resolver_from(vec!["example.com".to_string()]),
1147        );
1148        assert!(channel.is_sender_allowed("user@example.com"));
1149        assert!(channel.is_sender_allowed("admin@example.com"));
1150        assert!(!channel.is_sender_allowed("user@other.com"));
1151    }
1152
1153    #[test]
1154    fn is_sender_allowed_case_insensitive() {
1155        let channel = EmailChannel::new(
1156            EmailConfig::default(),
1157            "email_test_alias",
1158            resolver_from(vec!["Allowed@Example.COM".to_string()]),
1159        );
1160        assert!(channel.is_sender_allowed("allowed@example.com"));
1161        assert!(channel.is_sender_allowed("ALLOWED@EXAMPLE.COM"));
1162        assert!(channel.is_sender_allowed("AlLoWeD@eXaMpLe.cOm"));
1163    }
1164
1165    #[test]
1166    fn is_sender_allowed_multiple_senders() {
1167        let channel = EmailChannel::new(
1168            EmailConfig::default(),
1169            "email_test_alias",
1170            resolver_from(vec![
1171                "user1@example.com".to_string(),
1172                "user2@test.com".to_string(),
1173                "@allowed.com".to_string(),
1174            ]),
1175        );
1176        assert!(channel.is_sender_allowed("user1@example.com"));
1177        assert!(channel.is_sender_allowed("user2@test.com"));
1178        assert!(channel.is_sender_allowed("anyone@allowed.com"));
1179        assert!(!channel.is_sender_allowed("user3@example.com"));
1180    }
1181
1182    #[test]
1183    fn is_sender_allowed_wildcard_with_specific() {
1184        let channel = EmailChannel::new(
1185            EmailConfig::default(),
1186            "email_test_alias",
1187            resolver_from(vec!["*".to_string(), "specific@example.com".to_string()]),
1188        );
1189        assert!(channel.is_sender_allowed("anyone@example.com"));
1190        assert!(channel.is_sender_allowed("specific@example.com"));
1191    }
1192
1193    #[test]
1194    fn is_sender_allowed_empty_sender() {
1195        let channel = EmailChannel::new(
1196            EmailConfig::default(),
1197            "email_test_alias",
1198            resolver_from(vec!["@example.com".to_string()]),
1199        );
1200        assert!(!channel.is_sender_allowed(""));
1201        // "@example.com" ends with "@example.com" so it's allowed
1202        assert!(channel.is_sender_allowed("@example.com"));
1203    }
1204
1205    // strip_html tests
1206
1207    #[test]
1208    fn strip_html_basic() {
1209        assert_eq!(EmailChannel::strip_html("<p>Hello</p>"), "Hello");
1210        assert_eq!(EmailChannel::strip_html("<div>World</div>"), "World");
1211    }
1212
1213    #[test]
1214    fn strip_html_nested_tags() {
1215        assert_eq!(
1216            EmailChannel::strip_html("<div><p>Hello <strong>World</strong></p></div>"),
1217            "Hello World"
1218        );
1219    }
1220
1221    #[test]
1222    fn strip_html_multiple_lines() {
1223        let html = "<div>\n  <p>Line 1</p>\n  <p>Line 2</p>\n</div>";
1224        assert_eq!(EmailChannel::strip_html(html), "Line 1 Line 2");
1225    }
1226
1227    #[test]
1228    fn strip_html_preserves_text() {
1229        assert_eq!(EmailChannel::strip_html("No tags here"), "No tags here");
1230        assert_eq!(EmailChannel::strip_html(""), "");
1231    }
1232
1233    #[test]
1234    fn strip_html_handles_malformed() {
1235        assert_eq!(EmailChannel::strip_html("<p>Unclosed"), "Unclosed");
1236        // The function removes everything between < and >, so "Text>with>brackets" becomes "Textwithbrackets"
1237        assert_eq!(
1238            EmailChannel::strip_html("Text>with>brackets"),
1239            "Textwithbrackets"
1240        );
1241    }
1242
1243    #[test]
1244    fn strip_html_self_closing_tags() {
1245        // Self-closing tags are removed but don't add spaces
1246        assert_eq!(EmailChannel::strip_html("Hello<br/>World"), "HelloWorld");
1247        assert_eq!(EmailChannel::strip_html("Text<hr/>More"), "TextMore");
1248    }
1249
1250    #[test]
1251    fn strip_html_attributes_preserved() {
1252        assert_eq!(
1253            EmailChannel::strip_html("<a href=\"http://example.com\">Link</a>"),
1254            "Link"
1255        );
1256    }
1257
1258    #[test]
1259    fn strip_html_multiple_spaces_collapsed() {
1260        assert_eq!(
1261            EmailChannel::strip_html("<p>Word</p>  <p>Word</p>"),
1262            "Word Word"
1263        );
1264    }
1265
1266    #[test]
1267    fn strip_html_special_characters() {
1268        assert_eq!(
1269            EmailChannel::strip_html("<span>&lt;tag&gt;</span>"),
1270            "&lt;tag&gt;"
1271        );
1272    }
1273
1274    // Default function tests
1275
1276    #[test]
1277    fn default_imap_port_returns_993() {
1278        assert_eq!(default_imap_port(), 993);
1279    }
1280
1281    #[test]
1282    fn default_smtp_port_returns_465() {
1283        assert_eq!(default_smtp_port(), 465);
1284    }
1285
1286    #[test]
1287    fn default_imap_folder_returns_inbox() {
1288        assert_eq!(default_imap_folder(), "INBOX");
1289    }
1290
1291    #[test]
1292    fn default_true_returns_true() {
1293        assert!(default_true());
1294    }
1295
1296    // EmailConfig serialization tests
1297
1298    #[test]
1299    fn email_config_serialize_deserialize() {
1300        let config = EmailConfig {
1301            enabled: true,
1302            imap_host: "imap.example.com".to_string(),
1303            imap_port: 993,
1304            imap_folder: "INBOX".to_string(),
1305            smtp_host: "smtp.example.com".to_string(),
1306            smtp_port: 587,
1307            smtp_tls: true,
1308            username: "user@example.com".to_string(),
1309            password: "password123".to_string(),
1310            smtp_username: None,
1311            smtp_password: None,
1312            from_address: "bot@example.com".to_string(),
1313            idle_timeout_secs: 1740,
1314            poll_interval_secs: 60,
1315            default_subject: "Serialization Test".to_string(),
1316            max_attachment_bytes: default_max_attachment_bytes(),
1317            excluded_tools: vec![],
1318            html_body: true,
1319        };
1320
1321        let json = serde_json::to_string(&config).unwrap();
1322        let deserialized: EmailConfig = serde_json::from_str(&json).unwrap();
1323
1324        assert_eq!(deserialized.imap_host, config.imap_host);
1325        assert_eq!(deserialized.smtp_port, config.smtp_port);
1326        assert_eq!(deserialized.default_subject, config.default_subject);
1327    }
1328
1329    #[test]
1330    fn email_config_deserialize_with_defaults() {
1331        let json = r#"{
1332            "imap_host": "imap.test.com",
1333            "smtp_host": "smtp.test.com",
1334            "username": "user",
1335            "password": "pass",
1336            "from_address": "bot@test.com"
1337        }"#;
1338
1339        let config: EmailConfig = serde_json::from_str(json).unwrap();
1340        assert_eq!(config.imap_port, 993); // default
1341        assert_eq!(config.smtp_port, 465); // default
1342        assert!(config.smtp_tls); // default
1343        assert_eq!(config.idle_timeout_secs, 1740); // default
1344        assert_eq!(config.default_subject, "Re: Message"); // default
1345    }
1346
1347    #[test]
1348    fn idle_timeout_deserializes_explicit_value() {
1349        let json = r#"{
1350            "imap_host": "imap.test.com",
1351            "smtp_host": "smtp.test.com",
1352            "username": "user",
1353            "password": "pass",
1354            "from_address": "bot@test.com",
1355            "idle_timeout_secs": 900
1356        }"#;
1357        let config: EmailConfig = serde_json::from_str(json).unwrap();
1358        assert_eq!(config.idle_timeout_secs, 900);
1359    }
1360
1361    #[test]
1362    fn poll_interval_deserializes_as_independent_field() {
1363        // poll_interval_secs is a separate field from idle_timeout_secs —
1364        // used when the IMAP server does not advertise the IDLE capability.
1365        // Previously (pre-polling-fallback) it was a misleading serde alias
1366        // for idle_timeout_secs; that coupling has been removed.
1367        let json = r#"{
1368            "imap_host": "imap.test.com",
1369            "smtp_host": "smtp.test.com",
1370            "username": "user",
1371            "password": "pass",
1372            "from_address": "bot@test.com",
1373            "poll_interval_secs": 120
1374        }"#;
1375        let config: EmailConfig = serde_json::from_str(json).unwrap();
1376        assert_eq!(config.poll_interval_secs, 120);
1377        assert_eq!(config.idle_timeout_secs, 1740); // unchanged default
1378    }
1379
1380    #[test]
1381    fn poll_interval_has_default_when_unset() {
1382        let json = r#"{
1383            "imap_host": "imap.test.com",
1384            "smtp_host": "smtp.test.com",
1385            "username": "user",
1386            "password": "pass",
1387            "from_address": "bot@test.com"
1388        }"#;
1389        let config: EmailConfig = serde_json::from_str(json).unwrap();
1390        assert_eq!(config.poll_interval_secs, 60);
1391    }
1392
1393    #[test]
1394    fn idle_timeout_propagates_to_channel() {
1395        let config = EmailConfig {
1396            enabled: true,
1397            idle_timeout_secs: 600,
1398            ..Default::default()
1399        };
1400        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1401        assert_eq!(channel.config.idle_timeout_secs, 600);
1402    }
1403
1404    #[test]
1405    fn email_config_debug_output() {
1406        let config = EmailConfig {
1407            enabled: true,
1408            imap_host: "imap.debug.com".to_string(),
1409            ..Default::default()
1410        };
1411        let debug_str = format!("{:?}", config);
1412        assert!(debug_str.contains("imap.debug.com"));
1413    }
1414
1415    #[test]
1416    fn email_config_smtp_credentials_default_to_none() {
1417        let config = EmailConfig::default();
1418        assert!(config.smtp_username.is_none());
1419        assert!(config.smtp_password.is_none());
1420    }
1421
1422    #[test]
1423    fn smtp_credentials_fallback_to_shared() {
1424        let config = EmailConfig {
1425            username: "shared@example.com".to_string(),
1426            password: "shared_pass".to_string(),
1427            smtp_username: None,
1428            smtp_password: None,
1429            ..Default::default()
1430        };
1431        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1432        let creds = channel.smtp_credentials();
1433        // Credentials doesn't expose fields directly, so round-trip via a
1434        // fresh construction for comparison
1435        let expected =
1436            Credentials::new("shared@example.com".to_string(), "shared_pass".to_string());
1437        assert_eq!(creds, expected);
1438    }
1439
1440    #[test]
1441    fn smtp_credentials_uses_dedicated_fields() {
1442        let config = EmailConfig {
1443            username: "shared@example.com".to_string(),
1444            password: "shared_pass".to_string(),
1445            smtp_username: Some("smtp@example.com".to_string()),
1446            smtp_password: Some("smtp_pass".to_string()),
1447            ..Default::default()
1448        };
1449        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1450        let creds = channel.smtp_credentials();
1451        let expected = Credentials::new("smtp@example.com".to_string(), "smtp_pass".to_string());
1452        assert_eq!(creds, expected);
1453    }
1454}