commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
+1347
File diff suppressed because it is too large.
Load diff
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod connection;
|
||||
pub mod error;
|
||||
pub mod parse;
|
||||
pub(crate) mod pool;
|
||||
pub mod types;
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user