first commit
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+832
View File
@@ -0,0 +1,832 @@
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<Vec<ImapFolder>>,
headers: Mutex<Vec<ImapMessageHeader>>,
bodies: Mutex<Vec<ImapMessageBody>>,
folder_status: Mutex<Option<FolderStatus>>,
folder_status_ext: Mutex<Option<FolderStatusExtended>>,
should_fail: Mutex<Option<ImapError>>,
pub appended: Mutex<Vec<(String, Vec<u8>)>>,
pub header_requests: Mutex<Vec<(String, String)>>,
/// 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<ImapFolder>) -> Self {
*self.folders.lock().unwrap() = folders;
self
}
/// Pre-load message headers that `fetch_headers` will return.
pub fn with_headers(self, headers: Vec<ImapMessageHeader>) -> 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<ImapMessageBody>) -> 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<Vec<String>, 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<Vec<ImapFolder>, 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<FolderStatus, ImapError> {
{
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<Vec<ImapMessageHeader>, 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<ImapMessageBody, ImapError> {
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<Vec<(u32, Vec<String>)>, 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<FolderStatusExtended, ImapError> {
{
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<String>)>, 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<String>)> =
headers.iter().map(|h| (h.uid, h.flags.clone())).collect();
Ok((items, 0))
}
async fn get_quota(&self, _creds: &ImapCredentials) -> Result<Option<MailboxQuota>, 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<u64, ImapError> {
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("<p>Second</p>".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<ImapCredentials> {
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);
}
}
}