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