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
41pub struct EmailChannel {
48 pub config: EmailConfig,
49 pub alias: String,
52 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 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 fn is_email_sender_allowed(peers: &[String], email: &str) -> bool {
87 if peers.is_empty() {
88 return false; }
90 if peers.iter().any(|a| a == "*") {
91 return true; }
93 let email_lower = email.to_lowercase();
94 peers.iter().any(|allowed| {
95 if allowed.starts_with('@') {
96 email_lower.ends_with(&allowed.to_lowercase())
98 } else if allowed.contains('@') {
99 allowed.eq_ignore_ascii_case(email)
101 } else {
102 email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
104 }
105 })
106 }
107
108 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 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 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 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 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 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 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 let tcp = TcpStream::connect(&addr).await?;
226
227 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 let client = async_imap::Client::new(stream);
240
241 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 const MAX_FETCH_BATCH: usize = 10;
270
271 async fn fetch_unseen(&self, session: &mut ImapSession) -> Result<Vec<ParsedEmail>> {
278 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 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 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 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 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 let (wait_future, _stop_source) = idle.wait();
388
389 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 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 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 ::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 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 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 backoff = std::cmp::min(backoff * 2, max_backoff);
464 }
465 }
466 }
467 }
468
469 async fn run_session(&self, tx: &mpsc::Sender<ChannelMessage>) -> Result<()> {
472 let mut session = self.connect_imap().await?;
474
475 session.select(&self.config.imap_folder).await?;
477
478 let has_idle = {
482 let caps = session.capabilities().await?;
483 caps.has_str("IDLE")
484 };
485
486 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 async fn run_idle_inner(
515 &self,
516 mut session: ImapSession,
517 tx: &mpsc::Sender<ChannelMessage>,
518 ) -> Result<()> {
519 loop {
520 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 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 return Err(e);
547 }
548 }
549 }
550 }
551
552 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 session.noop().await?;
566 self.process_unseen(&mut session, tx).await?;
567 }
568 }
569
570 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 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 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
654struct 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
665enum 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 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 match timeout(Duration::from_secs(10), self.connect_imap()).await {
792 Ok(Ok(mut session)) => {
793 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
817fn 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 #[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 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 #[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 #[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 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 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 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 #[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 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 #[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 assert!(channel.is_sender_allowed("@example.com"));
1203 }
1204
1205 #[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 assert_eq!(
1238 EmailChannel::strip_html("Text>with>brackets"),
1239 "Textwithbrackets"
1240 );
1241 }
1242
1243 #[test]
1244 fn strip_html_self_closing_tags() {
1245 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><tag></span>"),
1270 "<tag>"
1271 );
1272 }
1273
1274 #[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 #[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); assert_eq!(config.smtp_port, 465); assert!(config.smtp_tls); assert_eq!(config.idle_timeout_secs, 1740); assert_eq!(config.default_subject, "Re: Message"); }
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 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); }
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 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}