use super::*; use std::sync::Mutex; /// A mock IMAP client that returns pre-loaded data. /// /// Uses interior mutability (`Mutex`) so it can be shared behind `&self`. /// Build it up with the `.with_*()` builder methods, then pass it wherever /// an `&dyn ImapClient` is needed. pub struct MockImapClient { folders: Mutex>, headers: Mutex>, bodies: Mutex>, folder_status: Mutex>, folder_status_ext: Mutex>, should_fail: Mutex>, pub appended: Mutex)>>, pub header_requests: Mutex>, /// One-shot gate after taking a header snapshot, before returning it. pub header_fetch_gate: Option<(tokio::sync::Notify, tokio::sync::Notify)>, } impl MockImapClient { /// Create a new empty mock. pub fn new() -> Self { Self { folders: Mutex::new(Vec::new()), headers: Mutex::new(Vec::new()), bodies: Mutex::new(Vec::new()), folder_status: Mutex::new(None), folder_status_ext: Mutex::new(None), should_fail: Mutex::new(None), appended: Mutex::new(Vec::new()), header_requests: Mutex::new(Vec::new()), header_fetch_gate: None, } } /// Pre-load folders that `list_folders` will return. pub fn with_folders(self, folders: Vec) -> Self { *self.folders.lock().unwrap() = folders; self } /// Pre-load message headers that `fetch_headers` will return. pub fn with_headers(self, headers: Vec) -> Self { *self.headers.lock().unwrap() = headers; self } /// Pre-load a folder status that `folder_status` will return. #[allow(dead_code)] pub fn with_folder_status(self, status: FolderStatus) -> Self { *self.folder_status.lock().unwrap() = Some(status); self } /// Pre-load an extended folder status that `folder_status_extended` will return. #[allow(dead_code)] pub fn with_folder_status_extended(self, status: FolderStatusExtended) -> Self { *self.folder_status_ext.lock().unwrap() = Some(status); self } /// Pre-load message bodies that `fetch_body` will match against by UID. pub fn with_bodies(self, bodies: Vec) -> Self { *self.bodies.lock().unwrap() = bodies; self } /// Make every subsequent call return this error. pub fn with_error(self, error: ImapError) -> Self { *self.should_fail.lock().unwrap() = Some(error); self } } /// Helper to clone an `ImapError` for the mock (the real errors are not /// `Clone`, so we reconstruct them). fn clone_error(err: &ImapError) -> ImapError { match err { ImapError::ConnectionFailed(msg) => ImapError::ConnectionFailed(msg.clone()), ImapError::AuthenticationFailed => ImapError::AuthenticationFailed, ImapError::FolderNotFound(name) => ImapError::FolderNotFound(name.clone()), ImapError::MessageNotFound { uid, folder } => ImapError::MessageNotFound { uid: *uid, folder: folder.clone(), }, ImapError::ProtocolError(msg) => ImapError::ProtocolError(msg.clone()), } } #[async_trait] impl ImapClient for MockImapClient { async fn capabilities(&self, _creds: &ImapCredentials) -> Result, ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(vec![ "IMAP4REV1".to_string(), "IDLE".to_string(), "MOVE".to_string(), "UIDPLUS".to_string(), ]) } async fn list_folders(&self, _creds: &ImapCredentials) -> Result, ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(self.folders.lock().unwrap().clone()) } async fn folder_status( &self, _creds: &ImapCredentials, _folder: &str, ) -> Result { { let fail = self.should_fail.lock().unwrap(); if let Some(ref err) = *fail { return Err(clone_error(err)); } } { let status = self.folder_status.lock().unwrap(); if let Some(ref s) = *status { return Ok(s.clone()); } } // Derive from headers (separate lock scope). let headers = self.headers.lock().unwrap(); let exists = headers.len() as u32; let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1; Ok(FolderStatus { uid_validity: 1, exists, uid_next, }) } async fn fetch_headers( &self, _creds: &ImapCredentials, folder: &str, uid_range: &str, ) -> Result, ImapError> { self.header_requests .lock() .unwrap() .push((folder.to_owned(), uid_range.to_owned())); if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } let headers = self.headers.lock().unwrap().clone(); if let Some((fetched, resume)) = &self.header_fetch_gate { fetched.notify_one(); resume.notified().await; } Ok(headers) } async fn fetch_body( &self, _creds: &ImapCredentials, _folder: &str, uid: u32, ) -> Result { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } let bodies = self.bodies.lock().unwrap(); bodies .iter() .find(|b| b.uid == uid) .cloned() .ok_or_else(|| ImapError::MessageNotFound { uid, folder: _folder.to_string(), }) } async fn add_flags( &self, _creds: &ImapCredentials, _folder: &str, _uid: u32, _flags: &[&str], ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn remove_flags( &self, _creds: &ImapCredentials, _folder: &str, _uid: u32, _flags: &[&str], ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn set_flags( &self, _creds: &ImapCredentials, _folder: &str, _uid: u32, _flags: &[&str], ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn move_message( &self, _creds: &ImapCredentials, _from_folder: &str, _uid: u32, _to_folder: &str, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn copy_message( &self, _creds: &ImapCredentials, _from_folder: &str, _uid: u32, _to_folder: &str, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn mark_all_read( &self, _creds: &ImapCredentials, _folder: &str, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn empty_folder( &self, _creds: &ImapCredentials, _folder: &str, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn expunge_message( &self, _creds: &ImapCredentials, _folder: &str, _uid: u32, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn append_message( &self, _creds: &ImapCredentials, folder: &str, message_bytes: &[u8], _flags: &[&str], ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } self.appended .lock() .unwrap() .push((folder.to_string(), message_bytes.to_vec())); Ok(()) } async fn append_message_resolved_uid( &self, creds: &ImapCredentials, folder: &str, message_bytes: &[u8], _flags: &[&str], _identity_header: &str, _identity_value: &str, ) -> Result<(u32, u32), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } let status = self.folder_status(creds, folder).await?; self.appended .lock() .unwrap() .push((folder.to_string(), message_bytes.to_vec())); Ok((status.uid_next.max(1), status.uid_validity)) } async fn create_folder( &self, _creds: &ImapCredentials, _folder_name: &str, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn rename_folder( &self, _creds: &ImapCredentials, _from: &str, _to: &str, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn delete_folder( &self, _creds: &ImapCredentials, _folder_name: &str, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn subscribe_folder( &self, _creds: &ImapCredentials, _folder_name: &str, _subscribe: bool, ) -> Result<(), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(()) } async fn fetch_uids_and_flags( &self, _creds: &ImapCredentials, _folder: &str, ) -> Result)>, ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } let headers = self.headers.lock().unwrap(); Ok(headers.iter().map(|h| (h.uid, h.flags.clone())).collect()) } async fn folder_status_extended( &self, _creds: &ImapCredentials, _folder: &str, ) -> Result { { let fail = self.should_fail.lock().unwrap(); if let Some(ref err) = *fail { return Err(clone_error(err)); } } { let status = self.folder_status_ext.lock().unwrap(); if let Some(ref s) = *status { return Ok(s.clone()); } } // Derive from headers. let headers = self.headers.lock().unwrap(); let exists = headers.len() as u32; let uid_next = headers.iter().map(|h| h.uid).max().unwrap_or(0) + 1; Ok(FolderStatusExtended { uid_validity: 1, exists, uid_next, unseen: 0, highest_modseq: 0, }) } async fn fetch_changed_flags( &self, _creds: &ImapCredentials, _folder: &str, _since_modseq: u64, ) -> Result<(Vec<(u32, Vec)>, u64), ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } // In mock, return all headers as "changed" with modseq 0. let headers = self.headers.lock().unwrap(); let items: Vec<(u32, Vec)> = headers.iter().map(|h| (h.uid, h.flags.clone())).collect(); Ok((items, 0)) } async fn get_quota(&self, _creds: &ImapCredentials) -> Result, ImapError> { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(None) } async fn fetch_folder_size( &self, _creds: &ImapCredentials, _folder: &str, ) -> Result { if let Some(ref err) = *self.should_fail.lock().unwrap() { return Err(clone_error(err)); } Ok(0) } } // ----------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// Convenience helper to build dummy credentials for tests. fn test_creds() -> ImapCredentials { ImapCredentials { host: "imap.example.com".to_string(), port: 993, tls: true, email: "user@example.com".to_string(), password: "hunter2".to_string(), } } #[tokio::test] async fn mock_list_folders_returns_preloaded_data() { let mock = MockImapClient::new().with_folders(vec![ ImapFolder { name: "INBOX".to_string(), delimiter: Some("/".to_string()), attributes: vec!["\\HasNoChildren".to_string()], }, ImapFolder { name: "Sent".to_string(), delimiter: Some("/".to_string()), attributes: vec![], }, ]); let folders = mock.list_folders(&test_creds()).await.unwrap(); assert_eq!(folders.len(), 2); assert_eq!(folders[0].name, "INBOX"); assert_eq!(folders[1].name, "Sent"); } #[tokio::test] async fn mock_fetch_headers_returns_preloaded_data() { let mock = MockImapClient::new().with_headers(vec![ImapMessageHeader { uid: 42, subject: Some("Hello".to_string()), from: vec![EmailAddress { name: Some("Alice".to_string()), address: "alice@example.com".to_string(), }], to: vec![EmailAddress { name: None, address: "bob@example.com".to_string(), }], date: Some("Mon, 1 Jan 2024 00:00:00 +0000".to_string()), flags: vec!["\\Seen".to_string()], has_attachments: false, size: 1024, message_id: None, in_reply_to: None, references: None, cc: vec![], reaction: None, }]); let headers = mock .fetch_headers(&test_creds(), "INBOX", "1:*") .await .unwrap(); assert_eq!(headers.len(), 1); assert_eq!(headers[0].uid, 42); assert_eq!(headers[0].subject.as_deref(), Some("Hello")); } #[tokio::test] async fn mock_fetch_body_returns_matching_uid() { let mock = MockImapClient::new().with_bodies(vec![ ImapMessageBody { uid: 1, text_plain: Some("First message".to_string()), text_html: None, attachments: vec![], raw_headers: String::new(), }, ImapMessageBody { uid: 2, text_plain: None, text_html: Some("

Second

".to_string()), attachments: vec![ImapAttachment { filename: Some("doc.pdf".to_string()), content_type: "application/pdf".to_string(), size: 1024, data: vec![0u8; 1024], content_id: None, }], raw_headers: String::new(), }, ]); let body = mock.fetch_body(&test_creds(), "INBOX", 2).await.unwrap(); assert_eq!(body.uid, 2); assert!(body.text_html.is_some()); assert_eq!(body.attachments.len(), 1); assert_eq!(body.attachments[0].filename.as_deref(), Some("doc.pdf")); } #[tokio::test] async fn mock_fetch_body_returns_not_found_for_missing_uid() { let mock = MockImapClient::new().with_bodies(vec![ImapMessageBody { uid: 1, text_plain: Some("only message".to_string()), text_html: None, attachments: vec![], raw_headers: String::new(), }]); let err = mock .fetch_body(&test_creds(), "INBOX", 999) .await .unwrap_err(); match err { ImapError::MessageNotFound { uid, folder } => { assert_eq!(uid, 999); assert_eq!(folder, "INBOX"); } other => panic!("Expected MessageNotFound, got: {other}"), } } #[tokio::test] async fn mock_with_error_overrides_all_methods() { let mock = MockImapClient::new() .with_folders(vec![ImapFolder { name: "INBOX".to_string(), delimiter: None, attributes: vec![], }]) .with_error(ImapError::AuthenticationFailed); let err = mock.list_folders(&test_creds()).await.unwrap_err(); assert!(matches!(err, ImapError::AuthenticationFailed)); let err = mock .fetch_headers(&test_creds(), "INBOX", "1:*") .await .unwrap_err(); assert!(matches!(err, ImapError::AuthenticationFailed)); let err = mock .set_flags(&test_creds(), "INBOX", 1, &["\\Seen"]) .await .unwrap_err(); assert!(matches!(err, ImapError::AuthenticationFailed)); let err = mock .move_message(&test_creds(), "INBOX", 1, "Trash") .await .unwrap_err(); assert!(matches!(err, ImapError::AuthenticationFailed)); let err = mock .expunge_message(&test_creds(), "INBOX", 1) .await .unwrap_err(); assert!(matches!(err, ImapError::AuthenticationFailed)); } #[tokio::test] async fn mock_set_flags_succeeds_without_error() { let mock = MockImapClient::new(); let result = mock .set_flags(&test_creds(), "INBOX", 1, &["\\Seen", "\\Flagged"]) .await; assert!(result.is_ok()); } #[tokio::test] async fn mock_move_message_succeeds_without_error() { let mock = MockImapClient::new(); let result = mock .move_message(&test_creds(), "INBOX", 1, "Archive") .await; assert!(result.is_ok()); } #[tokio::test] async fn mock_expunge_message_succeeds_without_error() { let mock = MockImapClient::new(); let result = mock.expunge_message(&test_creds(), "Trash", 5).await; assert!(result.is_ok()); } #[tokio::test] async fn real_imap_client_connection_fails_with_bad_host() { let client = RealImapClient::default(); let creds = test_creds(); let err = client.list_folders(&creds).await.unwrap_err(); // With a fake host the connection should fail. assert!( matches!(err, ImapError::ConnectionFailed(_)), "Expected ConnectionFailed, got: {err}" ); } #[tokio::test] async fn imap_error_display_formats_correctly() { let cases: Vec<(ImapError, &str)> = vec![ ( ImapError::ConnectionFailed("timeout".to_string()), "Connection failed: timeout", ), (ImapError::AuthenticationFailed, "Authentication failed"), ( ImapError::FolderNotFound("Drafts".to_string()), "Folder not found: Drafts", ), ( ImapError::MessageNotFound { uid: 7, folder: "INBOX".to_string(), }, "Message UID 7 not found in folder INBOX", ), ( ImapError::ProtocolError("unexpected EOF".to_string()), "Protocol error: unexpected EOF", ), ]; for (err, expected) in cases { assert_eq!(err.to_string(), expected); } } #[tokio::test] async fn email_address_serializes_and_deserializes() { let addr = EmailAddress { name: Some("Test User".to_string()), address: "test@example.com".to_string(), }; let json = serde_json::to_string(&addr).unwrap(); let deserialized: EmailAddress = serde_json::from_str(&json).unwrap(); assert_eq!(addr, deserialized); } #[tokio::test] async fn mock_append_message_succeeds() { let mock = MockImapClient::new(); let result = mock .append_message( &test_creds(), "Sent", b"From: test\r\n\r\nBody", &["\\Seen"], ) .await; assert!(result.is_ok()); } #[tokio::test] async fn mock_append_captures_data() { let mock = MockImapClient::new(); mock.append_message(&test_creds(), "Sent", b"test message", &["\\Seen"]) .await .unwrap(); let appended = mock.appended.lock().unwrap(); assert_eq!(appended.len(), 1); assert_eq!(appended[0].0, "Sent"); assert_eq!(appended[0].1, b"test message"); } #[tokio::test] async fn mock_empty_returns_empty_collections() { let mock = MockImapClient::new(); let creds = test_creds(); let folders = mock.list_folders(&creds).await.unwrap(); assert!(folders.is_empty()); let headers = mock.fetch_headers(&creds, "INBOX", "1:*").await.unwrap(); assert!(headers.is_empty()); } #[tokio::test] async fn mock_create_folder_succeeds() { let mock = MockImapClient::new(); let result = mock.create_folder(&test_creds(), "NewFolder").await; assert!(result.is_ok()); } #[tokio::test] async fn mock_rename_folder_succeeds() { let mock = MockImapClient::new(); let result = mock .rename_folder(&test_creds(), "OldName", "NewName") .await; assert!(result.is_ok()); } #[tokio::test] async fn mock_delete_folder_succeeds() { let mock = MockImapClient::new(); let result = mock.delete_folder(&test_creds(), "OldFolder").await; assert!(result.is_ok()); } #[tokio::test] async fn mock_subscribe_folder_succeeds() { let mock = MockImapClient::new(); let result = mock .subscribe_folder(&test_creds(), "SomeFolder", true) .await; assert!(result.is_ok()); let result = mock .subscribe_folder(&test_creds(), "SomeFolder", false) .await; assert!(result.is_ok()); } // ------------------------------------------------------------------- // Integration tests (run manually against a real IMAP server) // ------------------------------------------------------------------- // // cargo test real_imap -- --ignored // // Required env vars: // TEST_IMAP_HOST (e.g. "imap.gmail.com") // TEST_IMAP_PORT (e.g. "993") // TEST_IMAP_EMAIL (e.g. "you@gmail.com") // TEST_IMAP_PASSWORD (e.g. "app-password") // TEST_IMAP_TLS (e.g. "true") fn real_creds() -> Option { let host = std::env::var("TEST_IMAP_HOST").ok()?; let port: u16 = std::env::var("TEST_IMAP_PORT").ok()?.parse().ok()?; let email = std::env::var("TEST_IMAP_EMAIL").ok()?; let password = std::env::var("TEST_IMAP_PASSWORD").ok()?; let tls = std::env::var("TEST_IMAP_TLS") .unwrap_or_else(|_| "true".to_string()) .parse() .unwrap_or(true); Some(ImapCredentials { host, port, tls, email, password, }) } #[tokio::test] #[ignore] // Run manually: cargo test real_imap_list_folders -- --ignored async fn real_imap_list_folders() { let creds = real_creds().expect("TEST_IMAP_* env vars required"); let client = RealImapClient::default(); let folders = client.list_folders(&creds).await.unwrap(); assert!(!folders.is_empty(), "expected at least one folder"); let names: Vec<_> = folders.iter().map(|f| f.name.as_str()).collect(); assert!( names.iter().any(|n| n.eq_ignore_ascii_case("INBOX")), "expected INBOX in folder list, got: {names:?}" ); } #[tokio::test] #[ignore] // Run manually: cargo test real_imap_fetch_headers -- --ignored async fn real_imap_fetch_headers() { let creds = real_creds().expect("TEST_IMAP_* env vars required"); let client = RealImapClient::default(); let headers = client.fetch_headers(&creds, "INBOX", "1:5").await.unwrap(); // The mailbox might be empty, so we just check it doesn't error. for h in &headers { assert!(h.uid > 0); } } #[tokio::test] #[ignore] // Run manually: cargo test real_imap_fetch_body -- --ignored async fn real_imap_fetch_body() { let creds = real_creds().expect("TEST_IMAP_* env vars required"); let client = RealImapClient::default(); // First fetch headers to find a UID. let headers = client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap(); if let Some(h) = headers.first() { let body = client.fetch_body(&creds, "INBOX", h.uid).await.unwrap(); assert_eq!(body.uid, h.uid); } } }