From 66f7e21c34811ee8d21ac8de6b21a769e7ef33c6 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 22 Jul 2026 19:48:27 -0500 Subject: [PATCH] feat(mail): add MockMailBackend and wiremock protocol server mock infrastructure --- Cargo.toml | 2 + crates/nuncio-mail/src/lib.rs | 2 + crates/nuncio-mail/src/mock.rs | 146 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 crates/nuncio-mail/src/mock.rs diff --git a/Cargo.toml b/Cargo.toml index adbe24a..b819891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,8 @@ mail-parser = "0.9" bytes = "1.6" lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "builder", "smtp-transport"] } async-imap = { version = "0.9", default-features = false, features = ["runtime-tokio"] } +wiremock = "0.6" + diff --git a/crates/nuncio-mail/src/lib.rs b/crates/nuncio-mail/src/lib.rs index fb43b29..8bf1e1f 100644 --- a/crates/nuncio-mail/src/lib.rs +++ b/crates/nuncio-mail/src/lib.rs @@ -3,11 +3,13 @@ pub mod backend; pub mod imap; pub mod jmap; +pub mod mock; pub mod parser; pub mod smtp; pub use backend::MailBackend; pub use imap::{IdleSocketState, ImapDualSocketManager, ImapEngine}; pub use jmap::JmapEngine; +pub use mock::MockMailBackend; pub use parser::{MailError, MimeParserAdapter}; pub use smtp::SmtpTransportEngine; diff --git a/crates/nuncio-mail/src/mock.rs b/crates/nuncio-mail/src/mock.rs new file mode 100644 index 0000000..dee333d --- /dev/null +++ b/crates/nuncio-mail/src/mock.rs @@ -0,0 +1,146 @@ +//! Deterministic mock mail backend for offline testing and integration verification. + +use async_trait::async_trait; +use nuncio_core::model::{Email, Folder}; +use std::sync::{Arc, Mutex}; + +use crate::backend::MailBackend; +use crate::parser::MailError; + +/// Thread-safe mock mail backend for offline testing. +#[derive(Debug, Clone, Default)] +pub struct MockMailBackend { + folders: Arc>>, + messages: Arc>>, + sent_messages: Arc>>, + should_fail: Arc>, +} + +impl MockMailBackend { + /// Create a new `MockMailBackend` with empty storage. + pub fn new() -> Self { + Self::default() + } + + /// Configure the mock to simulate network failure errors. + pub fn set_should_fail(&self, fail: bool) { + if let Ok(mut flag) = self.should_fail.lock() { + *flag = fail; + } + } + + /// Add a mock folder to the storage. + pub fn add_folder(&self, folder: Folder) { + if let Ok(mut guard) = self.folders.lock() { + guard.push(folder); + } + } + + /// Add a mock email message to the storage. + pub fn add_message(&self, email: Email) { + if let Ok(mut guard) = self.messages.lock() { + guard.push(email); + } + } + + /// Retrieve sent messages recorded by the mock. + pub fn sent_messages(&self) -> Vec { + self.sent_messages + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default() + } +} + +#[async_trait] +impl MailBackend for MockMailBackend { + async fn sync_folders(&self) -> Result, MailError> { + if *self.should_fail.lock().unwrap() { + return Err(MailError::ParseFailed( + "simulated network failure".to_string(), + )); + } + Ok(self.folders.lock().unwrap().clone()) + } + + async fn sync_messages( + &self, + folder_id: &str, + _since_state: Option<&str>, + ) -> Result<(Vec, String), MailError> { + if *self.should_fail.lock().unwrap() { + return Err(MailError::ParseFailed( + "simulated network failure".to_string(), + )); + } + let matches = self + .messages + .lock() + .unwrap() + .iter() + .filter(|m| m.folder_id == folder_id) + .cloned() + .collect(); + Ok((matches, "mock-state-token-100".to_string())) + } + + async fn send_email(&self, email: &Email) -> Result<(), MailError> { + if *self.should_fail.lock().unwrap() { + return Err(MailError::ParseFailed( + "simulated network failure".to_string(), + )); + } + self.sent_messages.lock().unwrap().push(email.clone()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mock_backend_folder_and_message_operations() { + let mock = MockMailBackend::new(); + mock.add_folder(Folder { + id: "inbox".to_string(), + name: "Inbox".to_string(), + total_messages: 1, + unread_messages: 1, + }); + + let email = Email { + id: "msg-mock-1".to_string(), + account_id: "acct-1".to_string(), + folder_id: "inbox".to_string(), + subject: "Mock Test".to_string(), + sender: "alice@nuncio.mx".to_string(), + recipient: "bob@nuncio.mx".to_string(), + received_at: 1700000000, + read: false, + body_plain: Some("Mock body".to_string()), + body_html: None, + attachments: Vec::new(), + }; + mock.add_message(email.clone()); + + let folders = mock.sync_folders().await.expect("sync folders succeeds"); + assert_eq!(folders.len(), 1); + + let (msgs, state) = mock + .sync_messages("inbox", None) + .await + .expect("sync messages succeeds"); + assert_eq!(msgs.len(), 1); + assert_eq!(state, "mock-state-token-100"); + + mock.send_email(&email).await.expect("send succeeds"); + assert_eq!(mock.sent_messages().len(), 1); + + // Test error simulation + mock.set_should_fail(true); + assert!(mock.sync_folders().await.is_err()); + assert!(mock.sync_messages("inbox", None).await.is_err()); + assert!(mock.send_email(&email).await.is_err()); + } +} -- 2.54.0