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

+1347
View File
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
use super::error::ImapError;
use super::types::ImapCredentials;
// Counts connection attempts by credential identity for isolated regression tests.
#[cfg(test)]
static CONNECTION_COUNTS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<[u8; 32], usize>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
#[cfg(test)]
pub(super) fn connection_count(creds: &ImapCredentials) -> usize {
*CONNECTION_COUNTS
.lock()
.unwrap()
.get(&super::pool::identity(creds))
.unwrap_or(&0)
}
// ---- Connection helper ----------------------------------------------------
/// Establish an authenticated IMAP session.
///
/// Returns a `Session` over a TLS stream (when `creds.tls` is true) or a
/// plain TCP stream. Because the two stream types are different concrete
/// types we use an enum wrapper that implements the traits `async-imap`
/// requires (`tokio::io::AsyncRead + AsyncWrite + Unpin + Debug`).
pub(crate) async fn connect(
creds: &ImapCredentials,
) -> Result<async_imap::Session<ImapStream>, ImapError> {
#[cfg(test)]
{
*CONNECTION_COUNTS
.lock()
.unwrap()
.entry(super::pool::identity(creds))
.or_default() += 1;
}
let connect_future = tokio::net::TcpStream::connect((creds.host.as_str(), creds.port));
// 10 second timeout for the initial TCP connection
let tcp = tokio::time::timeout(std::time::Duration::from_secs(10), connect_future)
.await
.map_err(|_| ImapError::ConnectionFailed("connection timed out".to_string()))?
.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?;
if creds.tls {
let tls = async_native_tls::TlsConnector::new();
let tls_stream = tls
.connect(&creds.host, tcp)
.await
.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?;
let client = async_imap::Client::new(ImapStream::Tls(tls_stream));
let mut session = client
.login(&creds.email, &creds.password)
.await
.map_err(|(e, _)| classify_login_error(e))?;
maybe_enable_utf8_accept(&mut session).await?;
Ok(session)
} else {
let client = async_imap::Client::new(ImapStream::Plain(tcp));
let mut session = client
.login(&creds.email, &creds.password)
.await
.map_err(|(e, _)| classify_login_error(e))?;
maybe_enable_utf8_accept(&mut session).await?;
Ok(session)
}
}
/// Enable RFC 6855 UTF8=ACCEPT once per freshly authenticated session when
/// the server explicitly advertises both ENABLE and UTF8=ACCEPT. A tagged
/// NO/BAD is treated as a capability mismatch and leaves the session usable
/// in legacy mode; transport loss still invalidates the connection.
async fn maybe_enable_utf8_accept(
session: &mut async_imap::Session<ImapStream>,
) -> Result<(), ImapError> {
use async_imap::imap_proto::{Response, Status};
let capabilities = session.capabilities().await.map_err(map_imap_error)?;
let has_atom = |needle: &str| {
capabilities.iter().any(|capability| match capability {
async_imap::types::Capability::Atom(name) => name.eq_ignore_ascii_case(needle),
_ => false,
})
};
if !has_atom("ENABLE") || !has_atom("UTF8=ACCEPT") {
return Ok(());
}
let id = session
.run_command("ENABLE UTF8=ACCEPT")
.await
.map_err(map_imap_error)?;
while let Some(response) = session.read_response().await {
let response = response.map_err(|error| ImapError::ConnectionFailed(error.to_string()))?;
match response.parsed() {
Response::Done { tag, status, .. } if *tag == id => {
if *status != Status::Ok {
tracing::warn!(
?status,
"IMAP server advertised UTF8=ACCEPT but rejected ENABLE; continuing without it"
);
}
return Ok(());
}
Response::Data {
status: Status::Bye,
..
} => {
return Err(ImapError::ConnectionFailed(
"IMAP server closed the connection while enabling UTF8=ACCEPT".to_string(),
));
}
_ => {}
}
}
Err(ImapError::ConnectionFailed(
"connection lost while enabling UTF8=ACCEPT".to_string(),
))
}
/// Classify an `async_imap::error::Error` that occurred during LOGIN.
pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError {
match err {
async_imap::error::Error::No(_) => ImapError::AuthenticationFailed,
async_imap::error::Error::Io(e) => ImapError::ConnectionFailed(e.to_string()),
async_imap::error::Error::ConnectionLost => {
ImapError::ConnectionFailed("connection lost".to_string())
}
other => ImapError::ProtocolError(other.to_string()),
}
}
/// Map a generic `async_imap` error to our `ImapError`.
pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError {
match err {
async_imap::error::Error::No(msg) => ImapError::ProtocolError(format!("NO: {msg}")),
async_imap::error::Error::Io(e) => ImapError::ConnectionFailed(e.to_string()),
async_imap::error::Error::ConnectionLost => {
ImapError::ConnectionFailed("connection lost".to_string())
}
other => ImapError::ProtocolError(other.to_string()),
}
}
// ---- Stream enum ----------------------------------------------------------
/// A wrapper enum so that `Session` can be generic over a single type
/// regardless of whether TLS is used.
#[derive(Debug)]
pub(crate) enum ImapStream {
Tls(async_native_tls::TlsStream<tokio::net::TcpStream>),
Plain(tokio::net::TcpStream),
}
impl tokio::io::AsyncRead for ImapStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ImapStream::Tls(s) => std::pin::Pin::new(s).poll_read(cx, buf),
ImapStream::Plain(s) => std::pin::Pin::new(s).poll_read(cx, buf),
}
}
}
impl tokio::io::AsyncWrite for ImapStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
match self.get_mut() {
ImapStream::Tls(s) => std::pin::Pin::new(s).poll_write(cx, buf),
ImapStream::Plain(s) => std::pin::Pin::new(s).poll_write(cx, buf),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ImapStream::Tls(s) => std::pin::Pin::new(s).poll_flush(cx),
ImapStream::Plain(s) => std::pin::Pin::new(s).poll_flush(cx),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ImapStream::Tls(s) => std::pin::Pin::new(s).poll_shutdown(cx),
ImapStream::Plain(s) => std::pin::Pin::new(s).poll_shutdown(cx),
}
}
}
+32
View File
@@ -0,0 +1,32 @@
use std::fmt;
/// Errors that can occur during IMAP operations.
#[derive(Debug)]
pub enum ImapError {
/// Could not connect to the IMAP server.
ConnectionFailed(String),
/// The server rejected our credentials.
AuthenticationFailed,
/// The requested folder does not exist.
FolderNotFound(String),
/// The requested message UID was not found in the given folder.
MessageNotFound { uid: u32, folder: String },
/// A low-level IMAP protocol error.
ProtocolError(String),
}
impl fmt::Display for ImapError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ImapError::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"),
ImapError::AuthenticationFailed => write!(f, "Authentication failed"),
ImapError::FolderNotFound(name) => write!(f, "Folder not found: {name}"),
ImapError::MessageNotFound { uid, folder } => {
write!(f, "Message UID {uid} not found in folder {folder}")
}
ImapError::ProtocolError(msg) => write!(f, "Protocol error: {msg}"),
}
}
}
impl std::error::Error for ImapError {}
+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);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod client;
pub mod connection;
pub mod error;
pub mod parse;
pub(crate) mod pool;
pub mod types;
+193
View File
@@ -0,0 +1,193 @@
use super::types::EmailAddress;
// ---- Helper: convert NameAttribute to string ------------------------------
pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String {
use async_imap::types::NameAttribute;
match attr {
NameAttribute::NoInferiors => "\\Noinferiors".to_string(),
NameAttribute::NoSelect => "\\Noselect".to_string(),
NameAttribute::Marked => "\\Marked".to_string(),
NameAttribute::Unmarked => "\\Unmarked".to_string(),
NameAttribute::All => "\\All".to_string(),
NameAttribute::Archive => "\\Archive".to_string(),
NameAttribute::Drafts => "\\Drafts".to_string(),
NameAttribute::Flagged => "\\Flagged".to_string(),
NameAttribute::Junk => "\\Junk".to_string(),
NameAttribute::Sent => "\\Sent".to_string(),
NameAttribute::Trash => "\\Trash".to_string(),
NameAttribute::Extension(s) => s.to_string(),
_ => format!("{attr:?}"),
}
}
// ---- Helper: convert Flag to string ---------------------------------------
pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String {
use async_imap::types::Flag;
match flag {
Flag::Seen => "\\Seen".to_string(),
Flag::Answered => "\\Answered".to_string(),
Flag::Flagged => "\\Flagged".to_string(),
Flag::Deleted => "\\Deleted".to_string(),
Flag::Draft => "\\Draft".to_string(),
Flag::Recent => "\\Recent".to_string(),
Flag::MayCreate => "\\*".to_string(),
Flag::Custom(s) => s.to_string(),
}
}
// ---- Helper: decode RFC 2047 encoded words --------------------------------
/// Decode RFC 2047 encoded-word sequences like `=?utf-8?b?SGVsbG8=?=`.
pub(crate) fn decode_rfc2047(input: &str) -> String {
// Quick check: if there's no encoded-word marker, return as-is.
if !input.contains("=?") {
return input.to_string();
}
let mut result = String::with_capacity(input.len());
let mut remaining = input;
while let Some(start) = remaining.find("=?") {
// Add any text before the encoded word.
result.push_str(&remaining[..start]);
let after_start = &remaining[start + 2..];
// Find charset?encoding?text?= pattern.
if let Some(q1) = after_start.find('?') {
let charset = &after_start[..q1];
let after_charset = &after_start[q1 + 1..];
if let Some(q2) = after_charset.find('?') {
let encoding = &after_charset[..q2];
let after_encoding = &after_charset[q2 + 1..];
if let Some(end) = after_encoding.find("?=") {
let encoded_text = &after_encoding[..end];
let decoded_bytes = match encoding.to_ascii_uppercase().as_str() {
"B" => base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
encoded_text,
)
.ok(),
"Q" => decode_quoted_printable_header(encoded_text),
_ => None,
};
if let Some(bytes) = decoded_bytes {
let text = decode_charset(charset, &bytes);
result.push_str(&text);
} else {
// Failed to decode — keep the original encoded word.
let full_len = 2 + q1 + 1 + q2 + 1 + end + 2;
result.push_str(&remaining[start..start + full_len]);
}
// Skip past the encoded word.
let skip = q1 + 1 + q2 + 1 + end + 2;
remaining = &after_start[skip..];
// Strip whitespace between consecutive encoded words (RFC 2047 §6.2).
if remaining.starts_with("=?") || remaining.trim_start().starts_with("=?") {
remaining = remaining.trim_start();
}
continue;
}
}
}
// Malformed encoded word — keep the `=?` and move past it.
result.push_str("=?");
remaining = after_start;
}
result.push_str(remaining);
result
}
/// Decode quoted-printable as used in RFC 2047 headers (underscores = spaces).
pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
let mut bytes = Vec::with_capacity(input.len());
let mut chars = input.bytes();
while let Some(b) = chars.next() {
match b {
b'_' => bytes.push(b' '),
b'=' => {
let hi = chars.next()?;
let lo = chars.next()?;
let hex = [hi, lo];
let hex_str = std::str::from_utf8(&hex).ok()?;
let byte = u8::from_str_radix(hex_str, 16).ok()?;
bytes.push(byte);
}
_ => bytes.push(b),
}
}
Some(bytes)
}
/// Best-effort charset decoding. UTF-8 and Latin-1 are the most common.
pub(crate) fn decode_charset(charset: &str, bytes: &[u8]) -> String {
match charset.to_ascii_lowercase().as_str() {
"utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
"iso-8859-1" | "latin1" | "latin-1" => bytes.iter().map(|&b| b as char).collect(),
// For other charsets, try UTF-8 first, then fall back to Latin-1.
_ => String::from_utf8(bytes.to_vec())
.unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect()),
}
}
// ---- Helper: convert imap_proto::Address to EmailAddress ------------------
pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Address<'_>) -> EmailAddress {
let name = addr
.name
.as_ref()
.and_then(|b| std::str::from_utf8(b).ok())
.map(decode_rfc2047);
let mailbox = addr
.mailbox
.as_ref()
.and_then(|b| std::str::from_utf8(b).ok())
.unwrap_or("");
let host = addr
.host
.as_ref()
.and_then(|b| std::str::from_utf8(b).ok())
.unwrap_or("");
let address = if host.is_empty() {
mailbox.to_string()
} else {
format!("{mailbox}@{host}")
};
EmailAddress { name, address }
}
// ---- Helper: detect attachments from BODYSTRUCTURE -----------------------
pub(crate) fn has_attachments(bs: &async_imap::imap_proto::types::BodyStructure<'_>) -> bool {
use async_imap::imap_proto::types::BodyStructure;
match bs {
BodyStructure::Basic { common, .. } => {
// Check for explicit "attachment" disposition.
if let Some(ref disp) = common.disposition
&& disp.ty.eq_ignore_ascii_case("attachment")
{
return true;
}
// Non-text basic parts (image, application, audio, video) are likely attachments.
let ty = common.ty.ty.to_ascii_lowercase();
matches!(ty.as_str(), "application" | "image" | "audio" | "video")
}
BodyStructure::Text { common, .. } => {
// Text parts with "attachment" disposition count.
if let Some(ref disp) = common.disposition {
return disp.ty.eq_ignore_ascii_case("attachment");
}
false
}
BodyStructure::Message { body, .. } => has_attachments(body),
BodyStructure::Multipart { bodies, .. } => bodies.iter().any(has_attachments),
}
}
+551
View File
@@ -0,0 +1,551 @@
//! Request-path sessions only; IDLE owns a separate connection.
use super::{
connection::{ImapStream, connect},
error::ImapError,
types::ImapCredentials,
};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
ops::{Deref, DerefMut},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{
sync::{OwnedSemaphorePermit, Semaphore},
time::Instant,
};
/// Mark IMAP operations in this future as background work.
pub(crate) async fn background<T>(work: impl std::future::Future<Output = T>) -> T {
BACKGROUND.scope((), work).await
}
tokio::task_local! { static BACKGROUND: (); }
type Session = async_imap::Session<ImapStream>;
const MAX_SESSIONS: usize = 3;
const IDLE_TTL: Duration = Duration::from_secs(120);
const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Default)]
pub(crate) struct ConnectionPool {
inner: Arc<PoolInner>,
}
#[derive(Default)]
struct PoolInner {
users: Mutex<HashMap<[u8; 32], Arc<UserPool>>>,
reaper_started: AtomicBool,
}
struct UserPool {
identity_prefix: u32,
slots: Arc<Semaphore>,
background_slots: Arc<Semaphore>,
idle: Mutex<Vec<(Instant, Session)>>,
}
// Length-prefix fields to avoid ambiguous identities. Never retain plaintext passwords.
pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
let mut hash = Sha256::new();
for field in [
creds.host.as_bytes(),
&creds.port.to_be_bytes(),
&[u8::from(creds.tls)],
creds.email.as_bytes(),
creds.password.as_bytes(),
] {
hash.update((field.len() as u64).to_be_bytes());
hash.update(field);
}
hash.finalize().into()
}
// async-imap 0.10's noop parser accepts EOF without a tagged completion.
// Require the matching tagged OK ourselves so dead sockets cannot pass validation.
async fn validate(session: &mut Session) -> Result<(), ImapError> {
use async_imap::imap_proto::{Response, Status};
let id = session
.run_command("NOOP")
.await
.map_err(super::connection::map_imap_error)?;
while let Some(response) = session.read_response().await {
let response = response.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?;
match response.parsed() {
Response::Done { tag, status, .. } if *tag == id => {
return if *status == Status::Ok {
Ok(())
} else {
Err(ImapError::ProtocolError("NOOP rejected".into()))
};
}
Response::Data {
status: Status::Bye,
..
} => break,
_ => {}
}
}
Err(ImapError::ConnectionFailed(
"connection lost during NOOP".into(),
))
}
impl PoolInner {
fn evict(&self) {
self.users.lock().unwrap().retain(|_, user| {
let mut idle = user.idle.lock().unwrap();
// Dropping a session closes its socket; never CLOSE (which expunges).
idle.retain(|(returned, _)| returned.elapsed() < IDLE_TTL);
!idle.is_empty() || Arc::strong_count(user) > 1
});
}
}
impl ConnectionPool {
/// Drop all reusable request-path IMAP sessions for one account. Active
/// leases are detached from the pool and will be closed when returned.
pub(crate) fn clear_user(&self, creds: &ImapCredentials) {
self.inner.users.lock().unwrap().remove(&identity(creds));
}
pub(crate) async fn checkout(&self, creds: &ImapCredentials) -> Result<Lease, ImapError> {
if !self.inner.reaper_started.swap(true, Ordering::Relaxed) {
let weak = Arc::downgrade(&self.inner);
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let Some(inner) = weak.upgrade() else { break };
inner.evict();
}
});
}
let key = identity(creds);
let user = self
.inner
.users
.lock()
.unwrap()
.entry(key)
.or_insert_with(|| {
Arc::new(UserPool {
identity_prefix: u32::from_be_bytes(key[..4].try_into().unwrap()),
slots: Arc::new(Semaphore::new(MAX_SESSIONS)),
background_slots: Arc::new(Semaphore::new(MAX_SESSIONS - 1)),
idle: Mutex::new(Vec::new()),
})
})
.clone();
// Wait outside the shared budget so queued sync work cannot starve reads.
let background_permit = if BACKGROUND.try_with(|_| ()).is_ok() {
Some(
user.background_slots
.clone()
.acquire_owned()
.await
.map_err(|_| ImapError::ConnectionFailed("pool closed".into()))?,
)
} else {
None
};
let permit = tokio::time::timeout(CHECK_TIMEOUT, user.slots.clone().acquire_owned())
.await
.map_err(|_| ImapError::ConnectionFailed("pool checkout timed out".into()))?
.map_err(|_| ImapError::ConnectionFailed("pool closed".into()))?;
loop {
let candidate = user.idle.lock().unwrap().pop();
let Some((returned, mut session)) = candidate else {
break;
};
if returned.elapsed() < IDLE_TTL
&& matches!(
tokio::time::timeout(CHECK_TIMEOUT, validate(&mut session)).await,
Ok(Ok(()))
)
{
tracing::debug!(
pool_identity = format_args!("{:08x}", user.identity_prefix),
action = "reuse",
"IMAP pool checkout"
);
return Ok(Lease {
session: Some(session),
user,
_permit: permit,
_background_permit: background_permit,
});
}
tracing::debug!(
pool_identity = format_args!("{:08x}", user.identity_prefix),
action = "discard",
"IMAP pool validation failed or session expired"
);
}
tracing::debug!(
pool_identity = format_args!("{:08x}", user.identity_prefix),
action = "connect",
"IMAP pool checkout"
);
// Bound TCP, TLS and LOGIN together, retaining connect's TCP timeout too.
let session = tokio::time::timeout(CHECK_TIMEOUT, connect(creds))
.await
.map_err(|_| ImapError::ConnectionFailed("connection timed out".into()))??;
Ok(Lease {
session: Some(session),
user,
_permit: permit,
_background_permit: background_permit,
})
}
}
/// Defaults to discard, including when a request is cancelled mid-command.
/// Only explicitly completed operations may return the session for reuse.
pub(crate) struct Lease {
session: Option<Session>,
user: Arc<UserPool>,
_permit: OwnedSemaphorePermit,
_background_permit: Option<OwnedSemaphorePermit>,
}
impl Lease {
pub(crate) fn recycle(mut self) {
tracing::debug!(
pool_identity = format_args!("{:08x}", self.user.identity_prefix),
action = "recycle",
"IMAP pool return"
);
self.user
.idle
.lock()
.unwrap()
.push((Instant::now(), self.session.take().unwrap()));
}
}
impl Deref for Lease {
type Target = Session;
fn deref(&self) -> &Session {
self.session.as_ref().unwrap()
}
}
impl DerefMut for Lease {
fn deref_mut(&mut self) -> &mut Session {
self.session.as_mut().unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::imap::client::{ImapClient, RealImapClient};
use std::sync::atomic::AtomicUsize;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
async fn server() -> (
ImapCredentials,
Arc<AtomicUsize>,
Arc<AtomicBool>,
tokio::task::JoinHandle<()>,
) {
server_with_quota("{tag} OK no quota configured\r\n").await
}
async fn server_with_quota(
quota_reply: &'static str,
) -> (
ImapCredentials,
Arc<AtomicUsize>,
Arc<AtomicBool>,
tokio::task::JoinHandle<()>,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let creds = ImapCredentials {
host: "127.0.0.1".into(),
port: listener.local_addr().unwrap().port(),
tls: false,
email: "test@example.com".into(),
password: "secret".into(),
};
let logins = Arc::new(AtomicUsize::new(0));
let count = logins.clone();
let kill_noop = Arc::new(AtomicBool::new(false));
let kill = kill_noop.clone();
let task = tokio::spawn(async move {
while let Ok((socket, _)) = listener.accept().await {
let count = count.clone();
let kill = kill.clone();
tokio::spawn(async move {
let mut socket = BufReader::new(socket);
socket
.get_mut()
.write_all(b"* OK test server\r\n")
.await
.unwrap();
let mut line = String::new();
let mut selected = String::new();
loop {
line.clear();
if socket.read_line(&mut line).await.unwrap_or(0) == 0 {
break;
}
let tag = line.split_whitespace().next().unwrap();
if line.contains(" NOOP") && kill.swap(false, Ordering::SeqCst) {
break;
}
if line.contains(" GETQUOTAROOT ") && quota_reply.is_empty() {
break;
}
if line.contains(" UID FETCH ") && selected == "stalled" {
// No completion. The caller must discard on cancellation/timeout.
continue;
}
let response = if line.contains(" GETQUOTAROOT ") {
quota_reply.replace("{tag}", tag)
} else if line.contains(" UID FETCH ") && selected == "broken" {
break;
} else if line.contains(" UID FETCH ") && selected == "rejected" {
format!("{tag} NO fetch rejected\r\n")
} else if line.contains(" UID FETCH ") {
format!("* 1 FETCH (UID 1 RFC822.SIZE 42)\r\n{tag} OK fetched\r\n")
} else if line.contains(" LOGIN ") {
count.fetch_add(1, Ordering::SeqCst);
format!("{tag} OK authenticated\r\n")
} else if line.contains(" LIST ") {
format!("* LIST () \"/\" \"INBOX\"\r\n{tag} OK listed\r\n")
} else if line.contains(" SELECT ") && line.contains("missing") {
format!("{tag} NO missing folder\r\n")
} else if line.contains(" SELECT ") {
selected = line
.split_whitespace()
.nth(2)
.unwrap()
.trim_matches('"')
.to_string();
let exists = if selected == "INBOX" || selected == "Archive" {
0
} else {
1
};
format!(
"* {exists} EXISTS\r\n* OK [UIDVALIDITY 1] stable\r\n* OK [UIDNEXT 1] next\r\n{tag} OK selected\r\n"
)
} else {
format!("{tag} OK done\r\n")
};
if socket
.get_mut()
.write_all(response.as_bytes())
.await
.is_err()
{
break;
}
}
});
}
});
(creds, logins, kill_noop, task)
}
#[tokio::test]
async fn read_operations_reuse_login_and_errors_discard() {
let (creds, logins, _, task) = server().await;
let client = RealImapClient::default();
for _ in 0..5 {
assert_eq!(client.list_folders(&creds).await.unwrap()[0].name, "INBOX");
}
assert_eq!(logins.load(Ordering::SeqCst), 1);
assert!(client.folder_status(&creds, "missing").await.is_err());
client.list_folders(&creds).await.unwrap();
assert_eq!(logins.load(Ordering::SeqCst), 2);
task.abort();
}
#[tokio::test]
async fn background_work_leaves_an_interactive_slot() {
let (creds, _, _, task) = server().await;
let pool = ConnectionPool::default();
let first = background(pool.checkout(&creds)).await.unwrap();
let second = background(pool.checkout(&creds)).await.unwrap();
let third = background(pool.checkout(&creds));
tokio::pin!(third);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut third)
.await
.is_err()
);
let interactive = tokio::time::timeout(Duration::from_millis(200), pool.checkout(&creds))
.await
.expect("background work starved interactive checkout")
.unwrap();
drop(interactive);
drop(first);
let third = third.await.unwrap();
drop((second, third));
task.abort();
}
#[tokio::test]
async fn bounds_exclusive_leases_and_discards_cancelled_work() {
let (creds, logins, _, task) = server().await;
let pool = ConnectionPool::default();
let mut leases = Vec::new();
for _ in 0..MAX_SESSIONS {
leases.push(pool.checkout(&creds).await.unwrap());
}
assert!(
tokio::time::timeout(Duration::from_millis(50), pool.checkout(&creds))
.await
.is_err()
);
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS);
drop(leases.pop()); // Unfinished work must not be reused.
let lease = pool.checkout(&creds).await.unwrap();
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1);
lease.recycle();
let reused = pool.checkout(&creds).await.unwrap();
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1);
drop(reused);
task.abort();
}
#[tokio::test]
async fn stale_sessions_and_credential_identities_are_isolated() {
let (creds, logins, _, task) = server().await;
let pool = ConnectionPool::default();
pool.checkout(&creds).await.unwrap().recycle();
let mut changed = creds.clone();
changed.password = "different".into();
pool.checkout(&changed).await.unwrap().recycle();
assert_eq!(logins.load(Ordering::SeqCst), 2);
{
let users = pool.inner.users.lock().unwrap();
for user in users.values() {
for (returned, _) in user.idle.lock().unwrap().iter_mut() {
*returned = Instant::now() - IDLE_TTL;
}
}
}
pool.inner.evict();
assert!(pool.inner.users.lock().unwrap().is_empty());
pool.checkout(&creds).await.unwrap().recycle();
assert_eq!(logins.load(Ordering::SeqCst), 3);
task.abort();
}
#[tokio::test]
async fn dead_session_is_replaced_before_operation() {
let (creds, logins, kill_noop, task) = server().await;
let client = RealImapClient::default();
client.list_folders(&creds).await.unwrap();
kill_noop.store(true, Ordering::SeqCst);
client.list_folders(&creds).await.unwrap();
assert_eq!(logins.load(Ordering::SeqCst), 2);
task.abort();
}
#[tokio::test]
#[ignore = "requires TEST_IMAP_HOST, TEST_IMAP_EMAIL and TEST_IMAP_PASSWORD"]
async fn real_tls_reads_reuse_one_login() {
let creds = ImapCredentials {
host: std::env::var("TEST_IMAP_HOST").expect("TEST_IMAP_HOST required"),
port: 993,
tls: true,
email: std::env::var("TEST_IMAP_EMAIL").expect("TEST_IMAP_EMAIL required"),
password: std::env::var("TEST_IMAP_PASSWORD").expect("TEST_IMAP_PASSWORD required"),
};
let client = RealImapClient::default();
let before = super::super::connection::connection_count(&creds);
for _ in 0..3 {
client.list_folders(&creds).await.unwrap();
client.folder_status(&creds, "INBOX").await.unwrap();
client.fetch_headers(&creds, "INBOX", "1:1").await.unwrap();
let quota = client.get_quota(&creds).await.unwrap();
println!("Server returned quota: {}", quota.is_some());
client.fetch_folder_size(&creds, "INBOX").await.unwrap();
}
let connections = super::super::connection::connection_count(&creds) - before;
println!("15 TLS read operations established {connections} connections");
assert_eq!(connections, 1);
}
#[tokio::test]
async fn quota_fallback_reads_reuse_the_request_pool() {
let (creds, logins, _, task) = server().await;
let client = RealImapClient::default();
for _ in 0..5 {
client.list_folders(&creds).await.unwrap();
assert!(client.get_quota(&creds).await.unwrap().is_none());
// A server without a configured quota triggers a size read per folder.
assert_eq!(client.fetch_folder_size(&creds, "INBOX").await.unwrap(), 0);
assert_eq!(
client.fetch_folder_size(&creds, "Archive").await.unwrap(),
0
);
}
assert_eq!(logins.load(Ordering::SeqCst), 1);
task.abort();
}
#[tokio::test]
async fn quota_payload_is_consumed_before_reuse() {
let (creds, logins, _, task) = server_with_quota(
"* QUOTAROOT INBOX root\r\n* QUOTA root (STORAGE 10 100)\r\n{tag} OK quota\r\n",
)
.await;
let client = RealImapClient::default();
for _ in 0..3 {
let quota = client.get_quota(&creds).await.unwrap().unwrap();
assert_eq!(quota.usage_bytes, 10 * 1024);
assert_eq!(quota.limit_bytes, 100 * 1024);
client.list_folders(&creds).await.unwrap();
}
assert_eq!(logins.load(Ordering::SeqCst), 1);
task.abort();
}
#[tokio::test]
async fn rejected_quota_reuses_but_interrupted_quota_discards_session() {
for reply in [
"{tag} NO unsupported\r\n",
"{tag} BAD unsupported\r\n",
"* BYE closing\r\n",
"",
] {
let (creds, logins, _, task) = server_with_quota(reply).await;
let client = RealImapClient::default();
let result = client.get_quota(&creds).await;
if reply.contains("unsupported") {
assert!(result.unwrap().is_none());
} else {
assert!(result.is_err());
}
client.list_folders(&creds).await.unwrap();
assert_eq!(
logins.load(Ordering::SeqCst),
if reply.contains("unsupported") { 1 } else { 2 }
);
task.abort();
}
}
#[tokio::test]
async fn folder_size_completion_reuses_and_incomplete_work_discards() {
let (creds, logins, _, task) = server().await;
let client = RealImapClient::default();
for _ in 0..3 {
assert_eq!(client.fetch_folder_size(&creds, "Full").await.unwrap(), 42);
}
assert_eq!(logins.load(Ordering::SeqCst), 1);
for folder in ["rejected", "broken", "stalled"] {
let result = tokio::time::timeout(
Duration::from_millis(200),
client.fetch_folder_size(&creds, folder),
)
.await;
assert!(!matches!(result, Ok(Ok(_))));
client.list_folders(&creds).await.unwrap();
}
assert_eq!(logins.load(Ordering::SeqCst), 4);
task.abort();
}
}
+124
View File
@@ -0,0 +1,124 @@
use serde::{Deserialize, Serialize};
/// Represents an IMAP folder (mailbox).
#[derive(Debug, Clone, Serialize)]
pub struct ImapFolder {
/// Folder name as returned by the IMAP server (e.g. "INBOX", "Sent").
pub name: String,
/// Delimiter used by the server (e.g. "/" or ".").
pub delimiter: Option<String>,
/// IMAP attributes for this folder (e.g. `\Noselect`, `\HasChildren`).
pub attributes: Vec<String>,
}
/// A parsed email address with optional display name.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EmailAddress {
/// Display name, if present (e.g. "Alice Smith").
pub name: Option<String>,
/// The actual email address (e.g. "alice@example.com").
pub address: String,
}
/// A lightweight summary of an email message (envelope data).
#[derive(Debug, Clone, Serialize)]
pub struct ImapMessageHeader {
/// IMAP UID of the message within its folder.
pub uid: u32,
/// Subject line.
pub subject: Option<String>,
/// Sender(s) of the message.
pub from: Vec<EmailAddress>,
/// Recipient(s) of the message.
pub to: Vec<EmailAddress>,
/// Date header value (raw string from the server).
pub date: Option<String>,
/// IMAP flags currently set on this message (e.g. `\Seen`, `\Flagged`).
pub flags: Vec<String>,
/// Whether this message has attachments (derived from BODYSTRUCTURE).
pub has_attachments: bool,
/// RFC 2822 size of the message in bytes.
pub size: u32,
/// Message-ID header value for threading.
pub message_id: Option<String>,
/// In-Reply-To header value for threading.
pub in_reply_to: Option<String>,
/// References header value for threading.
pub references: Option<String>,
/// CC addresses.
pub cc: Vec<EmailAddress>,
/// Reaction emoji if this is an Outlook/Exchange reaction notification.
pub reaction: Option<String>,
}
/// The full body of an email message, including attachments.
#[derive(Debug, Clone, Serialize)]
pub struct ImapMessageBody {
/// IMAP UID of the message within its folder.
pub uid: u32,
/// Plain-text body part, if available.
pub text_plain: Option<String>,
/// HTML body part, if available.
pub text_html: Option<String>,
/// List of attachments found in the message.
pub attachments: Vec<ImapAttachment>,
/// Raw RFC 822 headers as a single string.
pub raw_headers: String,
}
/// Metadata about a single attachment in an email message.
#[derive(Debug, Clone, Serialize)]
pub struct ImapAttachment {
/// Filename of the attachment, if provided by the sender.
pub filename: Option<String>,
/// MIME content type (e.g. "application/pdf").
pub content_type: String,
/// Size in bytes.
pub size: usize,
/// Raw attachment content.
pub data: Vec<u8>,
/// Content-ID for inline images (e.g. "image001@01D1234"), without angle brackets.
pub content_id: Option<String>,
}
/// Parameters needed to establish an IMAP connection.
/// Passed explicitly to every trait method so the trait stays stateless.
#[derive(Debug, Clone)]
pub struct ImapCredentials {
pub host: String,
pub port: u16,
pub tls: bool,
pub email: String,
pub password: String,
}
/// Lightweight result of an IMAP `SELECT` command.
#[derive(Debug, Clone, Serialize)]
pub struct FolderStatus {
/// UIDVALIDITY - changes when the mailbox is rebuilt or UIDs are reassigned.
pub uid_validity: u32,
/// The total number of messages currently in the folder.
pub exists: u32,
/// The highest UID that will be assigned to the next appended message.
pub uid_next: u32,
}
/// Mailbox quota information from IMAP GETQUOTAROOT.
#[derive(Debug, Clone, Serialize)]
pub struct MailboxQuota {
/// Storage used in bytes (STORAGE resource reports in KB, we convert).
pub usage_bytes: u64,
/// Storage limit in bytes.
pub limit_bytes: u64,
}
/// Extended folder status from an IMAP `STATUS` command with CONDSTORE fields.
/// Used for cheap pre-checks before full sync.
#[derive(Debug, Clone, Serialize)]
pub struct FolderStatusExtended {
pub uid_validity: u32,
pub exists: u32,
pub uid_next: u32,
pub unseen: u32,
pub highest_modseq: u64,
}