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    ///
87    /// Domain-class email matching (`@host` / bare `host` admit a whole
88    /// domain; `user@host` is a full case-insensitive address) can't be
89    /// expressed by the `crate::allowlist::Match` modes, so the per-entry
90    /// comparison runs through `crate::allowlist::is_user_allowed_by`. `peers`
91    /// is the caller's freshly-resolved list; no allowlist state is cached.
92    fn is_email_sender_allowed(peers: &[String], email: &str) -> bool {
93        crate::allowlist::is_user_allowed_by(peers, email, |allowed, email| {
94            let email_lower = email.to_lowercase();
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 = smtp_credential_override(self.config.smtp_username.as_deref())
623            .unwrap_or(&self.config.username)
624            .to_owned();
625        let pass = smtp_credential_override(self.config.smtp_password.as_deref())
626            .unwrap_or(&self.config.password)
627            .to_owned();
628        Credentials::new(user, pass)
629    }
630
631    fn create_smtp_transport(&self) -> Result<SmtpTransport> {
632        let creds = self.smtp_credentials();
633        let transport = if self.config.smtp_tls {
634            SmtpTransport::relay(&self.config.smtp_host)?
635                .port(self.config.smtp_port)
636                .credentials(creds)
637                .build()
638        } else {
639            SmtpTransport::builder_dangerous(&self.config.smtp_host)
640                .port(self.config.smtp_port)
641                .credentials(creds)
642                .build()
643        };
644        Ok(transport)
645    }
646}
647
648/// Internal struct for parsed email data
649struct ParsedEmail {
650    _uid: u32,
651    msg_id: String,
652    sender: String,
653    subject: String,
654    content: String,
655    timestamp: u64,
656    attachments: Vec<zeroclaw_api::media::MediaAttachment>,
657}
658
659/// Result from waiting on IDLE
660enum IdleWaitResult {
661    NewMail,
662    Timeout,
663    Interrupted,
664}
665
666impl ::zeroclaw_api::attribution::Attributable for EmailChannel {
667    fn role(&self) -> ::zeroclaw_api::attribution::Role {
668        ::zeroclaw_api::attribution::Role::Channel(::zeroclaw_api::attribution::ChannelKind::Email)
669    }
670    fn alias(&self) -> &str {
671        &self.alias
672    }
673}
674
675fn markdown_to_html(md: &str) -> String {
676    let mut options = Options::empty();
677    options.insert(Options::ENABLE_TABLES);
678    options.insert(Options::ENABLE_STRIKETHROUGH);
679    let parser = Parser::new_ext(md, options);
680    let mut html_output = String::new();
681    html::push_html(&mut html_output, parser);
682    html_output
683}
684
685fn smtp_credential_override(value: Option<&str>) -> Option<&str> {
686    value.filter(|value| !value.trim().is_empty())
687}
688
689#[async_trait]
690
691impl Channel for EmailChannel {
692    fn name(&self) -> &str {
693        "email"
694    }
695
696    async fn send(&self, message: &SendMessage) -> Result<()> {
697        // Use explicit subject if provided, otherwise fall back to legacy parsing or default
698        let default_subject = self.config.default_subject.as_str();
699        let (subject, body) = if let Some(ref subj) = message.subject {
700            (subj.as_str(), message.content.as_str())
701        } else if message.content.starts_with("Subject: ") {
702            if let Some(pos) = message.content.find('\n') {
703                (&message.content[9..pos], message.content[pos + 1..].trim())
704            } else {
705                (default_subject, message.content.as_str())
706            }
707        } else {
708            (default_subject, message.content.as_str())
709        };
710
711        let mut builder = Message::builder()
712            .from(self.config.from_address.parse()?)
713            .to(message.recipient.parse()?)
714            .subject(subject);
715        if let Some(ref reply_id) = message.in_reply_to {
716            builder = builder.in_reply_to(reply_id.clone());
717        }
718        let mut att_parts: Vec<(String, Vec<u8>, ContentType)> = Vec::new();
719        for att in &message.attachments {
720            let content_type = att
721                .mime_type
722                .as_deref()
723                .and_then(|m| ContentType::parse(m).ok())
724                .unwrap_or_else(|| {
725                    ContentType::parse("application/octet-stream").expect("hardcoded MIME type")
726                });
727            let att_data = resolve_attachment_data(&att.file_name, &att.data)?;
728            let att_name = std::path::Path::new(&att.file_name)
729                .file_name()
730                .and_then(|n| n.to_str())
731                .unwrap_or(&att.file_name)
732                .to_string();
733            att_parts.push((att_name, att_data, content_type));
734        }
735
736        let email = if self.config.html_body {
737            let alt = MultiPart::alternative()
738                .singlepart(SinglePart::plain(body.to_string()))
739                .singlepart(SinglePart::html(markdown_to_html(body)));
740            if att_parts.is_empty() {
741                builder.multipart(alt)?
742            } else {
743                let mut mixed = MultiPart::mixed().multipart(alt);
744                for (name, data, ct) in att_parts {
745                    mixed = mixed.singlepart(Attachment::new(name).body(data, ct));
746                }
747                builder.multipart(mixed)?
748            }
749        } else {
750            let plain = SinglePart::plain(body.to_string());
751            if att_parts.is_empty() {
752                builder.singlepart(plain)?
753            } else {
754                let mut mixed = MultiPart::mixed().singlepart(plain);
755                for (name, data, ct) in att_parts {
756                    mixed = mixed.singlepart(Attachment::new(name).body(data, ct));
757                }
758                builder.multipart(mixed)?
759            }
760        };
761
762        let transport = self.create_smtp_transport()?;
763        transport.send(&email)?;
764        ::zeroclaw_log::record!(
765            INFO,
766            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
767            &format!(
768                "Email sent to {} ({} attachments)",
769                message.recipient,
770                message.attachments.len()
771            )
772        );
773        Ok(())
774    }
775
776    async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
777        ::zeroclaw_log::record!(
778            INFO,
779            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
780            &format!(
781                "Starting email channel on {} (IDLE preferred, polling fallback)",
782                self.config.imap_folder
783            )
784        );
785        self.listen_with_reconnect(tx).await
786    }
787
788    async fn health_check(&self) -> bool {
789        // Fully async health check - attempt IMAP connection
790        match timeout(Duration::from_secs(10), self.connect_imap()).await {
791            Ok(Ok(mut session)) => {
792                // Try to logout cleanly
793                let _ = session.logout().await;
794                true
795            }
796            Ok(Err(e)) => {
797                ::zeroclaw_log::record!(
798                    DEBUG,
799                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
800                    &format!("Health check failed: {}", e)
801                );
802                false
803            }
804            Err(_) => {
805                ::zeroclaw_log::record!(
806                    DEBUG,
807                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
808                    "Health check timed out"
809                );
810                false
811            }
812        }
813    }
814}
815
816/// Resolve the byte content of an attachment for sending.
817///
818/// # Trust boundary
819///
820/// `file_name` is treated as a file-system path **only** when `data` is empty.
821/// This fallback exists exclusively for internally constructed
822/// [`MediaAttachment`](zeroclaw_api::media::MediaAttachment) values whose
823/// bytes were intentionally omitted (e.g. created via
824/// [`MediaAttachment::from_file`](zeroclaw_api::media::MediaAttachment::from_file)
825/// after a round-trip through serialization).  Callers that build attachments
826/// from untrusted input — user messages, HTTP request bodies, or any external
827/// data source — **must** validate or constrain `file_name` before reaching
828/// this function; no additional path sanitization is applied here.
829///
830/// Read errors are propagated rather than silently suppressed.
831fn resolve_attachment_data(file_name: &str, data: &[u8]) -> anyhow::Result<Vec<u8>> {
832    if data.is_empty() && std::path::Path::new(file_name).exists() {
833        std::fs::read(file_name).map_err(|e| {
834            anyhow::Error::msg(format!("failed to read attachment '{}': {}", file_name, e))
835        })
836    } else {
837        Ok(data.to_vec())
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    fn default_imap_port() -> u16 {
844        993
845    }
846    fn default_smtp_port() -> u16 {
847        465
848    }
849    fn default_imap_folder() -> String {
850        "INBOX".into()
851    }
852    fn default_idle_timeout() -> u64 {
853        1740
854    }
855    fn default_true() -> bool {
856        true
857    }
858    fn default_max_attachment_bytes() -> usize {
859        25 * 1024 * 1024
860    }
861    use super::*;
862
863    // -- resolve_attachment_data tests --
864
865    #[test]
866    fn resolve_attachment_data_returns_provided_bytes_when_non_empty() {
867        let data = b"hello attachment".to_vec();
868        let result = resolve_attachment_data("ignored.bin", &data).unwrap();
869        assert_eq!(result, data);
870    }
871
872    #[test]
873    fn resolve_attachment_data_falls_back_to_file_when_data_empty_and_file_exists() {
874        let dir = tempfile::tempdir().unwrap();
875        let path = dir.path().join("att.txt");
876        std::fs::write(&path, b"file contents").unwrap();
877        let result = resolve_attachment_data(path.to_str().unwrap(), &[]).unwrap();
878        assert_eq!(result, b"file contents");
879    }
880
881    #[test]
882    fn resolve_attachment_data_returns_empty_when_data_empty_and_file_absent() {
883        // file_name does not exist on disk — should return empty vec, not error.
884        // Use a temp dir to guarantee the path does not exist, rather than a
885        // hard-coded /tmp path, for portability.
886        let dir = tempfile::tempdir().unwrap();
887        let absent = dir.path().join("does-not-exist.bin");
888        let result = resolve_attachment_data(absent.to_str().unwrap(), &[]).unwrap();
889        assert!(result.is_empty());
890    }
891
892    #[test]
893    fn resolve_attachment_data_propagates_read_error_on_unreadable_file() {
894        // Create a file, then make it unreadable (Unix only).
895        #[cfg(unix)]
896        {
897            use std::os::unix::fs::PermissionsExt;
898            let dir = tempfile::tempdir().unwrap();
899            let path = dir.path().join("locked.bin");
900            std::fs::write(&path, b"secret").unwrap();
901            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
902            // Permission enforcement is not guaranteed when running as root;
903            // skip rather than produce a false failure.  Reading from
904            // /proc/self/status is Linux-specific but that is where this test
905            // is most likely to run.  On other Unix systems the check falls
906            // back to the USER env var, which is a best-effort heuristic only.
907            #[cfg(target_os = "linux")]
908            let is_root = std::fs::read_to_string("/proc/self/status")
909                .ok()
910                .and_then(|s| {
911                    s.lines()
912                        .find(|l| l.starts_with("Uid:"))
913                        .and_then(|l| l.split_whitespace().nth(1))
914                        .and_then(|uid| uid.parse::<u32>().ok())
915                })
916                .map(|uid| uid == 0)
917                .unwrap_or(false);
918            #[cfg(not(target_os = "linux"))]
919            let is_root = std::env::var("USER").map(|u| u == "root").unwrap_or(false);
920            if is_root {
921                return;
922            }
923            let result = resolve_attachment_data(path.to_str().unwrap(), &[]);
924            assert!(result.is_err());
925        }
926    }
927
928    #[test]
929    fn default_smtp_port_uses_tls_port() {
930        assert_eq!(default_smtp_port(), 465);
931    }
932
933    #[test]
934    fn email_config_default_uses_tls_smtp_defaults() {
935        let config = EmailConfig::default();
936        assert_eq!(config.smtp_port, 465);
937        assert!(config.smtp_tls);
938    }
939
940    #[test]
941    fn default_idle_timeout_is_29_minutes() {
942        assert_eq!(default_idle_timeout(), 1740);
943    }
944
945    #[test]
946    fn max_fetch_batch_bounds_chunk_size() {
947        let cap = EmailChannel::MAX_FETCH_BATCH;
948        assert_eq!(cap, 10);
949
950        // Under cap: single chunk
951        let uids: Vec<u32> = (1..=3).collect();
952        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
953        assert_eq!(chunks.len(), 1);
954        assert_eq!(chunks[0].len(), 3);
955
956        // Exactly at cap: single chunk
957        let uids: Vec<u32> = (1..=10).collect();
958        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
959        assert_eq!(chunks.len(), 1);
960        assert_eq!(chunks[0].len(), 10);
961
962        // Over cap: two chunks
963        let uids: Vec<u32> = (1..=15).collect();
964        let chunks: Vec<&[u32]> = uids.chunks(cap).collect();
965        assert_eq!(chunks.len(), 2);
966        assert_eq!(chunks[0].len(), 10);
967        assert_eq!(chunks[1].len(), 5);
968    }
969
970    #[tokio::test]
971    async fn seen_messages_starts_empty() {
972        let channel =
973            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
974        let seen = channel.seen_messages.lock().await;
975        assert!(seen.is_empty());
976    }
977
978    #[tokio::test]
979    async fn seen_messages_tracks_unique_ids() {
980        let channel =
981            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
982        let mut seen = channel.seen_messages.lock().await;
983
984        assert!(seen.insert("first-id".to_string()));
985        assert!(!seen.insert("first-id".to_string()));
986        assert!(seen.insert("second-id".to_string()));
987        assert_eq!(seen.len(), 2);
988    }
989
990    // EmailConfig tests
991
992    #[test]
993    fn email_config_default() {
994        let config = EmailConfig::default();
995        assert_eq!(config.imap_host, "");
996        assert_eq!(config.imap_port, 993);
997        assert_eq!(config.imap_folder, "INBOX");
998        assert_eq!(config.smtp_host, "");
999        assert_eq!(config.smtp_port, 465);
1000        assert!(config.smtp_tls);
1001        assert_eq!(config.username, "");
1002        assert_eq!(config.password, "");
1003        assert_eq!(config.from_address, "");
1004        assert_eq!(config.idle_timeout_secs, 1740);
1005    }
1006
1007    // EmailChannel tests
1008    //
1009    // Inbound peer authorization lives in `peer_groups` in V3; the
1010    // channel resolves the authorized senders via a peer_resolver
1011    // closure provided at construction.
1012
1013    fn empty_resolver() -> Arc<dyn Fn() -> Vec<String> + Send + Sync> {
1014        Arc::new(Vec::new)
1015    }
1016
1017    fn resolver_from(peers: Vec<String>) -> Arc<dyn Fn() -> Vec<String> + Send + Sync> {
1018        Arc::new(move || peers.clone())
1019    }
1020
1021    #[test]
1022    fn email_config_custom() {
1023        let config = EmailConfig {
1024            enabled: true,
1025            imap_host: "imap.example.com".to_string(),
1026            imap_port: 993,
1027            imap_folder: "Archive".to_string(),
1028            smtp_host: "smtp.example.com".to_string(),
1029            smtp_port: 465,
1030            smtp_tls: true,
1031            username: "user@example.com".to_string(),
1032            password: "pass123".to_string(),
1033            smtp_username: None,
1034            smtp_password: None,
1035            from_address: "bot@example.com".to_string(),
1036            idle_timeout_secs: 1200,
1037            poll_interval_secs: 60,
1038            default_subject: "Custom Subject".to_string(),
1039            max_attachment_bytes: default_max_attachment_bytes(),
1040            html_body: true,
1041            excluded_tools: vec![],
1042        };
1043        assert_eq!(config.imap_host, "imap.example.com");
1044        assert_eq!(config.imap_folder, "Archive");
1045        assert_eq!(config.idle_timeout_secs, 1200);
1046        assert_eq!(config.default_subject, "Custom Subject");
1047    }
1048
1049    #[test]
1050    fn email_config_clone() {
1051        let config = EmailConfig {
1052            enabled: true,
1053            imap_host: "imap.test.com".to_string(),
1054            imap_port: 993,
1055            imap_folder: "INBOX".to_string(),
1056            smtp_host: "smtp.test.com".to_string(),
1057            smtp_port: 587,
1058            smtp_tls: true,
1059            username: "user@test.com".to_string(),
1060            password: "secret".to_string(),
1061            smtp_username: None,
1062            smtp_password: None,
1063            from_address: "bot@test.com".to_string(),
1064            idle_timeout_secs: 1740,
1065            poll_interval_secs: 60,
1066            default_subject: "Test Subject".to_string(),
1067            max_attachment_bytes: default_max_attachment_bytes(),
1068            html_body: true,
1069            excluded_tools: vec![],
1070        };
1071        let cloned = config.clone();
1072        assert_eq!(cloned.imap_host, config.imap_host);
1073        assert_eq!(cloned.smtp_port, config.smtp_port);
1074        assert_eq!(cloned.default_subject, config.default_subject);
1075    }
1076
1077    #[tokio::test]
1078    async fn email_channel_new() {
1079        let config = EmailConfig::default();
1080        let channel = EmailChannel::new(config.clone(), "email_test_alias", empty_resolver());
1081        assert_eq!(channel.config.imap_host, config.imap_host);
1082
1083        let seen_guard = channel.seen_messages.lock().await;
1084        assert_eq!(seen_guard.len(), 0);
1085    }
1086
1087    #[test]
1088    fn email_channel_name() {
1089        let channel =
1090            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
1091        assert_eq!(channel.name(), "email");
1092    }
1093
1094    // is_sender_allowed tests
1095
1096    #[test]
1097    fn is_sender_allowed_empty_list_denies_all() {
1098        let channel =
1099            EmailChannel::new(EmailConfig::default(), "email_test_alias", empty_resolver());
1100        assert!(!channel.is_sender_allowed("anyone@example.com"));
1101        assert!(!channel.is_sender_allowed("user@test.com"));
1102    }
1103
1104    #[test]
1105    fn is_sender_allowed_wildcard_allows_all() {
1106        let channel = EmailChannel::new(
1107            EmailConfig::default(),
1108            "email_test_alias",
1109            resolver_from(vec!["*".to_string()]),
1110        );
1111        assert!(channel.is_sender_allowed("anyone@example.com"));
1112        assert!(channel.is_sender_allowed("user@test.com"));
1113        assert!(channel.is_sender_allowed("random@domain.org"));
1114    }
1115
1116    #[test]
1117    fn is_sender_allowed_specific_email() {
1118        let channel = EmailChannel::new(
1119            EmailConfig::default(),
1120            "email_test_alias",
1121            resolver_from(vec!["allowed@example.com".to_string()]),
1122        );
1123        assert!(channel.is_sender_allowed("allowed@example.com"));
1124        assert!(!channel.is_sender_allowed("other@example.com"));
1125        assert!(!channel.is_sender_allowed("allowed@other.com"));
1126    }
1127
1128    #[test]
1129    fn is_sender_allowed_domain_with_at_prefix() {
1130        let channel = EmailChannel::new(
1131            EmailConfig::default(),
1132            "email_test_alias",
1133            resolver_from(vec!["@example.com".to_string()]),
1134        );
1135        assert!(channel.is_sender_allowed("user@example.com"));
1136        assert!(channel.is_sender_allowed("admin@example.com"));
1137        assert!(!channel.is_sender_allowed("user@other.com"));
1138    }
1139
1140    #[test]
1141    fn is_sender_allowed_domain_without_at_prefix() {
1142        let channel = EmailChannel::new(
1143            EmailConfig::default(),
1144            "email_test_alias",
1145            resolver_from(vec!["example.com".to_string()]),
1146        );
1147        assert!(channel.is_sender_allowed("user@example.com"));
1148        assert!(channel.is_sender_allowed("admin@example.com"));
1149        assert!(!channel.is_sender_allowed("user@other.com"));
1150    }
1151
1152    #[test]
1153    fn is_sender_allowed_case_insensitive() {
1154        let channel = EmailChannel::new(
1155            EmailConfig::default(),
1156            "email_test_alias",
1157            resolver_from(vec!["Allowed@Example.COM".to_string()]),
1158        );
1159        assert!(channel.is_sender_allowed("allowed@example.com"));
1160        assert!(channel.is_sender_allowed("ALLOWED@EXAMPLE.COM"));
1161        assert!(channel.is_sender_allowed("AlLoWeD@eXaMpLe.cOm"));
1162    }
1163
1164    #[test]
1165    fn is_sender_allowed_multiple_senders() {
1166        let channel = EmailChannel::new(
1167            EmailConfig::default(),
1168            "email_test_alias",
1169            resolver_from(vec![
1170                "user1@example.com".to_string(),
1171                "user2@test.com".to_string(),
1172                "@allowed.com".to_string(),
1173            ]),
1174        );
1175        assert!(channel.is_sender_allowed("user1@example.com"));
1176        assert!(channel.is_sender_allowed("user2@test.com"));
1177        assert!(channel.is_sender_allowed("anyone@allowed.com"));
1178        assert!(!channel.is_sender_allowed("user3@example.com"));
1179    }
1180
1181    #[test]
1182    fn is_sender_allowed_wildcard_with_specific() {
1183        let channel = EmailChannel::new(
1184            EmailConfig::default(),
1185            "email_test_alias",
1186            resolver_from(vec!["*".to_string(), "specific@example.com".to_string()]),
1187        );
1188        assert!(channel.is_sender_allowed("anyone@example.com"));
1189        assert!(channel.is_sender_allowed("specific@example.com"));
1190    }
1191
1192    #[test]
1193    fn is_sender_allowed_empty_sender() {
1194        let channel = EmailChannel::new(
1195            EmailConfig::default(),
1196            "email_test_alias",
1197            resolver_from(vec!["@example.com".to_string()]),
1198        );
1199        assert!(!channel.is_sender_allowed(""));
1200        // "@example.com" ends with "@example.com" so it's allowed
1201        assert!(channel.is_sender_allowed("@example.com"));
1202    }
1203
1204    // strip_html tests
1205
1206    #[test]
1207    fn strip_html_basic() {
1208        assert_eq!(EmailChannel::strip_html("<p>Hello</p>"), "Hello");
1209        assert_eq!(EmailChannel::strip_html("<div>World</div>"), "World");
1210    }
1211
1212    #[test]
1213    fn strip_html_nested_tags() {
1214        assert_eq!(
1215            EmailChannel::strip_html("<div><p>Hello <strong>World</strong></p></div>"),
1216            "Hello World"
1217        );
1218    }
1219
1220    #[test]
1221    fn strip_html_multiple_lines() {
1222        let html = "<div>\n  <p>Line 1</p>\n  <p>Line 2</p>\n</div>";
1223        assert_eq!(EmailChannel::strip_html(html), "Line 1 Line 2");
1224    }
1225
1226    #[test]
1227    fn strip_html_preserves_text() {
1228        assert_eq!(EmailChannel::strip_html("No tags here"), "No tags here");
1229        assert_eq!(EmailChannel::strip_html(""), "");
1230    }
1231
1232    #[test]
1233    fn strip_html_handles_malformed() {
1234        assert_eq!(EmailChannel::strip_html("<p>Unclosed"), "Unclosed");
1235        // The function removes everything between < and >, so "Text>with>brackets" becomes "Textwithbrackets"
1236        assert_eq!(
1237            EmailChannel::strip_html("Text>with>brackets"),
1238            "Textwithbrackets"
1239        );
1240    }
1241
1242    #[test]
1243    fn strip_html_self_closing_tags() {
1244        // Self-closing tags are removed but don't add spaces
1245        assert_eq!(EmailChannel::strip_html("Hello<br/>World"), "HelloWorld");
1246        assert_eq!(EmailChannel::strip_html("Text<hr/>More"), "TextMore");
1247    }
1248
1249    #[test]
1250    fn strip_html_attributes_preserved() {
1251        assert_eq!(
1252            EmailChannel::strip_html("<a href=\"http://example.com\">Link</a>"),
1253            "Link"
1254        );
1255    }
1256
1257    #[test]
1258    fn strip_html_multiple_spaces_collapsed() {
1259        assert_eq!(
1260            EmailChannel::strip_html("<p>Word</p>  <p>Word</p>"),
1261            "Word Word"
1262        );
1263    }
1264
1265    #[test]
1266    fn strip_html_special_characters() {
1267        assert_eq!(
1268            EmailChannel::strip_html("<span>&lt;tag&gt;</span>"),
1269            "&lt;tag&gt;"
1270        );
1271    }
1272
1273    // Default function tests
1274
1275    #[test]
1276    fn default_imap_port_returns_993() {
1277        assert_eq!(default_imap_port(), 993);
1278    }
1279
1280    #[test]
1281    fn default_smtp_port_returns_465() {
1282        assert_eq!(default_smtp_port(), 465);
1283    }
1284
1285    #[test]
1286    fn default_imap_folder_returns_inbox() {
1287        assert_eq!(default_imap_folder(), "INBOX");
1288    }
1289
1290    #[test]
1291    fn default_true_returns_true() {
1292        assert!(default_true());
1293    }
1294
1295    // EmailConfig serialization tests
1296
1297    #[test]
1298    fn email_config_serialize_deserialize() {
1299        let config = EmailConfig {
1300            enabled: true,
1301            imap_host: "imap.example.com".to_string(),
1302            imap_port: 993,
1303            imap_folder: "INBOX".to_string(),
1304            smtp_host: "smtp.example.com".to_string(),
1305            smtp_port: 587,
1306            smtp_tls: true,
1307            username: "user@example.com".to_string(),
1308            password: "password123".to_string(),
1309            smtp_username: None,
1310            smtp_password: None,
1311            from_address: "bot@example.com".to_string(),
1312            idle_timeout_secs: 1740,
1313            poll_interval_secs: 60,
1314            default_subject: "Serialization Test".to_string(),
1315            max_attachment_bytes: default_max_attachment_bytes(),
1316            excluded_tools: vec![],
1317            html_body: true,
1318        };
1319
1320        let json = serde_json::to_string(&config).unwrap();
1321        let deserialized: EmailConfig = serde_json::from_str(&json).unwrap();
1322
1323        assert_eq!(deserialized.imap_host, config.imap_host);
1324        assert_eq!(deserialized.smtp_port, config.smtp_port);
1325        assert_eq!(deserialized.default_subject, config.default_subject);
1326    }
1327
1328    #[test]
1329    fn email_config_deserialize_with_defaults() {
1330        let json = r#"{
1331            "imap_host": "imap.test.com",
1332            "smtp_host": "smtp.test.com",
1333            "username": "user",
1334            "password": "pass",
1335            "from_address": "bot@test.com"
1336        }"#;
1337
1338        let config: EmailConfig = serde_json::from_str(json).unwrap();
1339        assert_eq!(config.imap_port, 993); // default
1340        assert_eq!(config.smtp_port, 465); // default
1341        assert!(config.smtp_tls); // default
1342        assert_eq!(config.idle_timeout_secs, 1740); // default
1343        assert_eq!(config.default_subject, "Re: Message"); // default
1344    }
1345
1346    #[test]
1347    fn idle_timeout_deserializes_explicit_value() {
1348        let json = r#"{
1349            "imap_host": "imap.test.com",
1350            "smtp_host": "smtp.test.com",
1351            "username": "user",
1352            "password": "pass",
1353            "from_address": "bot@test.com",
1354            "idle_timeout_secs": 900
1355        }"#;
1356        let config: EmailConfig = serde_json::from_str(json).unwrap();
1357        assert_eq!(config.idle_timeout_secs, 900);
1358    }
1359
1360    #[test]
1361    fn poll_interval_deserializes_as_independent_field() {
1362        // poll_interval_secs is a separate field from idle_timeout_secs —
1363        // used when the IMAP server does not advertise the IDLE capability.
1364        // Previously (pre-polling-fallback) it was a misleading serde alias
1365        // for idle_timeout_secs; that coupling has been removed.
1366        let json = r#"{
1367            "imap_host": "imap.test.com",
1368            "smtp_host": "smtp.test.com",
1369            "username": "user",
1370            "password": "pass",
1371            "from_address": "bot@test.com",
1372            "poll_interval_secs": 120
1373        }"#;
1374        let config: EmailConfig = serde_json::from_str(json).unwrap();
1375        assert_eq!(config.poll_interval_secs, 120);
1376        assert_eq!(config.idle_timeout_secs, 1740); // unchanged default
1377    }
1378
1379    #[test]
1380    fn poll_interval_has_default_when_unset() {
1381        let json = r#"{
1382            "imap_host": "imap.test.com",
1383            "smtp_host": "smtp.test.com",
1384            "username": "user",
1385            "password": "pass",
1386            "from_address": "bot@test.com"
1387        }"#;
1388        let config: EmailConfig = serde_json::from_str(json).unwrap();
1389        assert_eq!(config.poll_interval_secs, 60);
1390    }
1391
1392    #[test]
1393    fn idle_timeout_propagates_to_channel() {
1394        let config = EmailConfig {
1395            enabled: true,
1396            idle_timeout_secs: 600,
1397            ..Default::default()
1398        };
1399        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1400        assert_eq!(channel.config.idle_timeout_secs, 600);
1401    }
1402
1403    #[test]
1404    fn email_config_debug_output() {
1405        let config = EmailConfig {
1406            enabled: true,
1407            imap_host: "imap.debug.com".to_string(),
1408            ..Default::default()
1409        };
1410        let debug_str = format!("{:?}", config);
1411        assert!(debug_str.contains("imap.debug.com"));
1412    }
1413
1414    #[test]
1415    fn email_config_smtp_credentials_default_to_none() {
1416        let config = EmailConfig::default();
1417        assert!(config.smtp_username.is_none());
1418        assert!(config.smtp_password.is_none());
1419    }
1420
1421    #[test]
1422    fn smtp_credentials_fallback_to_shared() {
1423        let config = EmailConfig {
1424            username: "shared@example.com".to_string(),
1425            password: "shared_pass".to_string(),
1426            smtp_username: None,
1427            smtp_password: None,
1428            ..Default::default()
1429        };
1430        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1431        let creds = channel.smtp_credentials();
1432        // Credentials doesn't expose fields directly, so round-trip via a
1433        // fresh construction for comparison
1434        let expected =
1435            Credentials::new("shared@example.com".to_string(), "shared_pass".to_string());
1436        assert_eq!(creds, expected);
1437    }
1438
1439    #[test]
1440    fn smtp_credentials_uses_dedicated_fields() {
1441        let config = EmailConfig {
1442            username: "shared@example.com".to_string(),
1443            password: "shared_pass".to_string(),
1444            smtp_username: Some("smtp@example.com".to_string()),
1445            smtp_password: Some("smtp_pass".to_string()),
1446            ..Default::default()
1447        };
1448        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1449        let creds = channel.smtp_credentials();
1450        let expected = Credentials::new("smtp@example.com".to_string(), "smtp_pass".to_string());
1451        assert_eq!(creds, expected);
1452    }
1453
1454    #[test]
1455    fn smtp_credentials_ignore_blank_dedicated_fields() {
1456        let config = EmailConfig {
1457            username: "shared@example.com".to_string(),
1458            password: "shared_pass".to_string(),
1459            smtp_username: Some("   ".to_string()),
1460            smtp_password: Some("".to_string()),
1461            ..Default::default()
1462        };
1463        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1464        let creds = channel.smtp_credentials();
1465        let expected =
1466            Credentials::new("shared@example.com".to_string(), "shared_pass".to_string());
1467        assert_eq!(creds, expected);
1468    }
1469
1470    #[test]
1471    fn smtp_credentials_preserve_nonblank_dedicated_fields() {
1472        let config = EmailConfig {
1473            username: "shared@example.com".to_string(),
1474            password: "shared_pass".to_string(),
1475            smtp_username: Some("  smtp@example.com  ".to_string()),
1476            smtp_password: Some("  smtp_pass  ".to_string()),
1477            ..Default::default()
1478        };
1479        let channel = EmailChannel::new(config, "email_test_alias", empty_resolver());
1480        let creds = channel.smtp_credentials();
1481        let expected = Credentials::new(
1482            "  smtp@example.com  ".to_string(),
1483            "  smtp_pass  ".to_string(),
1484        );
1485        assert_eq!(creds, expected);
1486    }
1487}