diff --git a/src/channels/email_channel.rs b/src/channels/email_channel.rs index 4fcfd71..ce03be2 100644 --- a/src/channels/email_channel.rs +++ b/src/channels/email_channel.rs @@ -14,14 +14,11 @@ use lettre::transport::smtp::authentication::Credentials; use lettre::{Message, SmtpTransport, Transport}; use mail_parser::{MessageParser, MimeHeaders}; use serde::{Deserialize, Serialize}; -use std::collections::{HashSet, VecDeque}; +use std::collections::HashSet; use std::io::Write as IoWrite; use std::net::TcpStream; use std::sync::Mutex; use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -/// Maximum number of seen message IDs to retain before evicting the oldest. -const SEEN_MESSAGES_CAPACITY: usize = 100_000; use tokio::sync::mpsc; use tokio::time::{interval, sleep}; use tracing::{error, info, warn}; @@ -96,56 +93,17 @@ impl Default for EmailConfig { } } -/// Bounded dedup set that evicts oldest entries when capacity is reached. -struct BoundedSeenSet { - set: HashSet, - order: VecDeque, - capacity: usize, -} - -impl BoundedSeenSet { - fn new(capacity: usize) -> Self { - Self { - set: HashSet::with_capacity(capacity.min(1024)), - order: VecDeque::with_capacity(capacity.min(1024)), - capacity, - } - } - - fn contains(&self, id: &str) -> bool { - self.set.contains(id) - } - - fn insert(&mut self, id: String) -> bool { - if self.set.contains(&id) { - return false; - } - if self.order.len() >= self.capacity { - if let Some(oldest) = self.order.pop_front() { - self.set.remove(&oldest); - } - } - self.order.push_back(id.clone()); - self.set.insert(id); - true - } - - fn len(&self) -> usize { - self.set.len() - } -} - /// Email channel β€” IMAP polling for inbound, SMTP for outbound pub struct EmailChannel { pub config: EmailConfig, - seen_messages: Mutex, + seen_messages: Mutex>, } impl EmailChannel { pub fn new(config: EmailConfig) -> Self { Self { config, - seen_messages: Mutex::new(BoundedSeenSet::new(SEEN_MESSAGES_CAPACITY)), + seen_messages: Mutex::new(HashSet::new()), } } @@ -454,7 +412,7 @@ impl Channel for EmailChannel { Ok(Ok(messages)) => { for (id, sender, content, ts) in messages { { - let mut seen = self.seen_messages.lock().unwrap(); + let mut seen = self.seen_messages.lock().expect("seen_messages mutex should not be poisoned"); if seen.contains(&id) { continue; } @@ -501,7 +459,7 @@ impl Channel for EmailChannel { #[cfg(test)] mod tests { - use super::{BoundedSeenSet, EmailChannel}; + use super::*; #[test] fn build_imap_tls_config_succeeds() { @@ -534,7 +492,6 @@ mod tests { set.insert("c".into()); assert_eq!(set.len(), 3); - // Inserting a 4th should evict "a" set.insert("d".into()); assert_eq!(set.len(), 3); assert!(!set.contains("a"), "oldest entry should be evicted"); @@ -570,4 +527,343 @@ mod tests { assert!(set.contains("b")); assert_eq!(set.len(), 1); } + + // EmailConfig tests + + #[test] + fn email_config_default() { + let config = EmailConfig::default(); + assert_eq!(config.imap_host, ""); + assert_eq!(config.imap_port, 993); + assert_eq!(config.imap_folder, "INBOX"); + assert_eq!(config.smtp_host, ""); + assert_eq!(config.smtp_port, 587); + assert!(config.smtp_tls); + assert_eq!(config.username, ""); + assert_eq!(config.password, ""); + assert_eq!(config.from_address, ""); + assert_eq!(config.poll_interval_secs, 60); + assert!(config.allowed_senders.is_empty()); + } + + #[test] + fn email_config_custom() { + let config = EmailConfig { + imap_host: "imap.example.com".to_string(), + imap_port: 993, + imap_folder: "Archive".to_string(), + smtp_host: "smtp.example.com".to_string(), + smtp_port: 465, + smtp_tls: true, + username: "user@example.com".to_string(), + password: "pass123".to_string(), + from_address: "bot@example.com".to_string(), + poll_interval_secs: 30, + allowed_senders: vec!["allowed@example.com".to_string()], + }; + assert_eq!(config.imap_host, "imap.example.com"); + assert_eq!(config.imap_folder, "Archive"); + assert_eq!(config.poll_interval_secs, 30); + } + + #[test] + fn email_config_clone() { + let config = EmailConfig { + imap_host: "imap.test.com".to_string(), + imap_port: 993, + imap_folder: "INBOX".to_string(), + smtp_host: "smtp.test.com".to_string(), + smtp_port: 587, + smtp_tls: true, + username: "user@test.com".to_string(), + password: "secret".to_string(), + from_address: "bot@test.com".to_string(), + poll_interval_secs: 120, + allowed_senders: vec!["*".to_string()], + }; + let cloned = config.clone(); + assert_eq!(cloned.imap_host, config.imap_host); + assert_eq!(cloned.smtp_port, config.smtp_port); + assert_eq!(cloned.allowed_senders, config.allowed_senders); + } + + // EmailChannel tests + + #[test] + fn email_channel_new() { + let config = EmailConfig::default(); + let channel = EmailChannel::new(config.clone()); + assert_eq!(channel.config.imap_host, config.imap_host); + + let seen_guard = channel + .seen_messages + .lock() + .expect("seen_messages mutex should not be poisoned"); + assert_eq!(seen_guard.len(), 0); + } + + #[test] + fn email_channel_name() { + let channel = EmailChannel::new(EmailConfig::default()); + assert_eq!(channel.name(), "email"); + } + + // is_sender_allowed tests + + #[test] + fn is_sender_allowed_empty_list_denies_all() { + let config = EmailConfig { + allowed_senders: vec![], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(!channel.is_sender_allowed("anyone@example.com")); + assert!(!channel.is_sender_allowed("user@test.com")); + } + + #[test] + fn is_sender_allowed_wildcard_allows_all() { + let config = EmailConfig { + allowed_senders: vec!["*".to_string()], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(channel.is_sender_allowed("anyone@example.com")); + assert!(channel.is_sender_allowed("user@test.com")); + assert!(channel.is_sender_allowed("random@domain.org")); + } + + #[test] + fn is_sender_allowed_specific_email() { + let config = EmailConfig { + allowed_senders: vec!["allowed@example.com".to_string()], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(channel.is_sender_allowed("allowed@example.com")); + assert!(!channel.is_sender_allowed("other@example.com")); + assert!(!channel.is_sender_allowed("allowed@other.com")); + } + + #[test] + fn is_sender_allowed_domain_with_at_prefix() { + let config = EmailConfig { + allowed_senders: vec!["@example.com".to_string()], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(channel.is_sender_allowed("user@example.com")); + assert!(channel.is_sender_allowed("admin@example.com")); + assert!(!channel.is_sender_allowed("user@other.com")); + } + + #[test] + fn is_sender_allowed_domain_without_at_prefix() { + let config = EmailConfig { + allowed_senders: vec!["example.com".to_string()], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(channel.is_sender_allowed("user@example.com")); + assert!(channel.is_sender_allowed("admin@example.com")); + assert!(!channel.is_sender_allowed("user@other.com")); + } + + #[test] + fn is_sender_allowed_case_insensitive() { + let config = EmailConfig { + allowed_senders: vec!["Allowed@Example.COM".to_string()], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(channel.is_sender_allowed("allowed@example.com")); + assert!(channel.is_sender_allowed("ALLOWED@EXAMPLE.COM")); + assert!(channel.is_sender_allowed("AlLoWeD@eXaMpLe.cOm")); + } + + #[test] + fn is_sender_allowed_multiple_senders() { + let config = EmailConfig { + allowed_senders: vec![ + "user1@example.com".to_string(), + "user2@test.com".to_string(), + "@allowed.com".to_string(), + ], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(channel.is_sender_allowed("user1@example.com")); + assert!(channel.is_sender_allowed("user2@test.com")); + assert!(channel.is_sender_allowed("anyone@allowed.com")); + assert!(!channel.is_sender_allowed("user3@example.com")); + } + + #[test] + fn is_sender_allowed_wildcard_with_specific() { + let config = EmailConfig { + allowed_senders: vec!["*".to_string(), "specific@example.com".to_string()], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(channel.is_sender_allowed("anyone@example.com")); + assert!(channel.is_sender_allowed("specific@example.com")); + } + + #[test] + fn is_sender_allowed_empty_sender() { + let config = EmailConfig { + allowed_senders: vec!["@example.com".to_string()], + ..Default::default() + }; + let channel = EmailChannel::new(config); + assert!(!channel.is_sender_allowed("")); + // "@example.com" ends with "@example.com" so it's allowed + assert!(channel.is_sender_allowed("@example.com")); + } + + // strip_html tests + + #[test] + fn strip_html_basic() { + assert_eq!(EmailChannel::strip_html("

Hello

"), "Hello"); + assert_eq!(EmailChannel::strip_html("
World
"), "World"); + } + + #[test] + fn strip_html_nested_tags() { + assert_eq!( + EmailChannel::strip_html("

Hello World

"), + "Hello World" + ); + } + + #[test] + fn strip_html_multiple_lines() { + let html = "
\n

Line 1

\n

Line 2

\n
"; + assert_eq!(EmailChannel::strip_html(html), "Line 1 Line 2"); + } + + #[test] + fn strip_html_preserves_text() { + assert_eq!(EmailChannel::strip_html("No tags here"), "No tags here"); + assert_eq!(EmailChannel::strip_html(""), ""); + } + + #[test] + fn strip_html_handles_malformed() { + assert_eq!(EmailChannel::strip_html("

Unclosed"), "Unclosed"); + // The function removes everything between < and >, so "Text>with>brackets" becomes "Textwithbrackets" + assert_eq!(EmailChannel::strip_html("Text>with>brackets"), "Textwithbrackets"); + } + + #[test] + fn strip_html_self_closing_tags() { + // Self-closing tags are removed but don't add spaces + assert_eq!(EmailChannel::strip_html("Hello
World"), "HelloWorld"); + assert_eq!(EmailChannel::strip_html("Text


More"), "TextMore"); + } + + #[test] + fn strip_html_attributes_preserved() { + assert_eq!( + EmailChannel::strip_html("Link"), + "Link" + ); + } + + #[test] + fn strip_html_multiple_spaces_collapsed() { + assert_eq!( + EmailChannel::strip_html("

Word

Word

"), + "Word Word" + ); + } + + #[test] + fn strip_html_special_characters() { + assert_eq!( + EmailChannel::strip_html("<tag>"), + "<tag>" + ); + } + + // Default function tests + + #[test] + fn default_imap_port_returns_993() { + assert_eq!(default_imap_port(), 993); + } + + #[test] + fn default_smtp_port_returns_587() { + assert_eq!(default_smtp_port(), 587); + } + + #[test] + fn default_imap_folder_returns_inbox() { + assert_eq!(default_imap_folder(), "INBOX"); + } + + #[test] + fn default_poll_interval_returns_60() { + assert_eq!(default_poll_interval(), 60); + } + + #[test] + fn default_true_returns_true() { + assert!(default_true()); + } + + // EmailConfig serialization tests + + #[test] + fn email_config_serialize_deserialize() { + let config = EmailConfig { + imap_host: "imap.example.com".to_string(), + imap_port: 993, + imap_folder: "INBOX".to_string(), + smtp_host: "smtp.example.com".to_string(), + smtp_port: 587, + smtp_tls: true, + username: "user@example.com".to_string(), + password: "password123".to_string(), + from_address: "bot@example.com".to_string(), + poll_interval_secs: 30, + allowed_senders: vec!["allowed@example.com".to_string()], + }; + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: EmailConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.imap_host, config.imap_host); + assert_eq!(deserialized.smtp_port, config.smtp_port); + assert_eq!(deserialized.allowed_senders, config.allowed_senders); + } + + #[test] + fn email_config_deserialize_with_defaults() { + let json = r#"{ + "imap_host": "imap.test.com", + "smtp_host": "smtp.test.com", + "username": "user", + "password": "pass", + "from_address": "bot@test.com" + }"#; + + let config: EmailConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.imap_port, 993); // default + assert_eq!(config.smtp_port, 587); // default + assert!(config.smtp_tls); // default + assert_eq!(config.poll_interval_secs, 60); // default + } + + #[test] + fn email_config_debug_output() { + let config = EmailConfig { + imap_host: "imap.debug.com".to_string(), + ..Default::default() + }; + let debug_str = format!("{:?}", config); + assert!(debug_str.contains("imap.debug.com")); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 3e68065..2198cce 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -1066,7 +1066,7 @@ mod tests { #[test] fn whatsapp_signature_valid() { // Test with known values - let app_secret = "test_secret_key"; + let app_secret = "test_secret_key_12345"; let body = b"test body content"; let signature_header = compute_whatsapp_signature_header(app_secret, body); @@ -1080,8 +1080,8 @@ mod tests { #[test] fn whatsapp_signature_invalid_wrong_secret() { - let app_secret = "correct_secret"; - let wrong_secret = "wrong_secret"; + let app_secret = "correct_secret_key_abc"; + let wrong_secret = "wrong_secret_key_xyz"; let body = b"test body content"; let signature_header = compute_whatsapp_signature_header(wrong_secret, body); @@ -1095,7 +1095,7 @@ mod tests { #[test] fn whatsapp_signature_invalid_wrong_body() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let original_body = b"original body"; let tampered_body = b"tampered body"; @@ -1111,7 +1111,7 @@ mod tests { #[test] fn whatsapp_signature_missing_prefix() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = b"test body"; // Signature without "sha256=" prefix @@ -1126,7 +1126,7 @@ mod tests { #[test] fn whatsapp_signature_empty_header() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = b"test body"; assert!(!verify_whatsapp_signature(app_secret, body, "")); @@ -1134,7 +1134,7 @@ mod tests { #[test] fn whatsapp_signature_invalid_hex() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = b"test body"; // Invalid hex characters @@ -1149,7 +1149,7 @@ mod tests { #[test] fn whatsapp_signature_empty_body() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = b""; let signature_header = compute_whatsapp_signature_header(app_secret, body); @@ -1163,7 +1163,7 @@ mod tests { #[test] fn whatsapp_signature_unicode_body() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = "Hello πŸ¦€ δΈ–η•Œ".as_bytes(); let signature_header = compute_whatsapp_signature_header(app_secret, body); @@ -1177,7 +1177,7 @@ mod tests { #[test] fn whatsapp_signature_json_payload() { - let app_secret = "my_app_secret_from_meta"; + let app_secret = "test_app_secret_key_xyz"; let body = br#"{"entry":[{"changes":[{"value":{"messages":[{"from":"1234567890","text":{"body":"Hello"}}]}}]}]}"#; let signature_header = compute_whatsapp_signature_header(app_secret, body); @@ -1191,7 +1191,7 @@ mod tests { #[test] fn whatsapp_signature_case_sensitive_prefix() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = b"test body"; let hex_sig = compute_whatsapp_signature_hex(app_secret, body); @@ -1207,7 +1207,7 @@ mod tests { #[test] fn whatsapp_signature_truncated_hex() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = b"test body"; let hex_sig = compute_whatsapp_signature_hex(app_secret, body); @@ -1223,7 +1223,7 @@ mod tests { #[test] fn whatsapp_signature_extra_bytes() { - let app_secret = "test_secret"; + let app_secret = "test_secret_key_12345"; let body = b"test body"; let hex_sig = compute_whatsapp_signature_hex(app_secret, body);