use async_trait::async_trait; use futures::StreamExt; use super::connection::map_imap_error; use super::parse::{ decode_header_bytes, flag_to_string, has_attachments, imap_address_to_email, name_attribute_to_string, }; pub use super::error::ImapError; pub use super::types::{ EmailAddress, FolderStatus, FolderStatusExtended, ImapAttachment, ImapCredentials, ImapFolder, ImapMessageBody, ImapMessageHeader, MailboxQuota, }; pub(crate) use super::connection::connect; #[cfg(test)] #[path = "mock.rs"] pub mod mock; async fn folder_status_extended_on_session( session: &mut async_imap::Session, folder: &str, ) -> Result { if let Ok(mailbox) = session .status( folder, "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)", ) .await && let Some(unseen) = mailbox.unseen { return Ok(FolderStatusExtended { uid_validity: mailbox.uid_validity.unwrap_or(0), exists: mailbox.exists, uid_next: mailbox.uid_next.unwrap_or(0), unseen, highest_modseq: mailbox.highest_modseq.unwrap_or(0), }); } if let Ok(mailbox) = session .status(folder, "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN)") .await && let Some(unseen) = mailbox.unseen { return Ok(FolderStatusExtended { uid_validity: mailbox.uid_validity.unwrap_or(0), exists: mailbox.exists, uid_next: mailbox.uid_next.unwrap_or(0), unseen, highest_modseq: mailbox.highest_modseq.unwrap_or(0), }); } let mailbox = match session.select_condstore(folder).await { Ok(mailbox) => mailbox, Err(_) => session.select(folder).await.map_err(map_imap_error)?, }; let unseen = session .uid_search("UNSEEN") .await .map_err(map_imap_error)? .len() as u32; Ok(FolderStatusExtended { uid_validity: mailbox.uid_validity.unwrap_or(0), exists: mailbox.exists, uid_next: mailbox.uid_next.unwrap_or(0), unseen, highest_modseq: mailbox.highest_modseq.unwrap_or(0), }) } #[allow(dead_code)] #[allow(clippy::double_must_use)] #[async_trait] pub trait ImapClient: Send + Sync { async fn capabilities(&self, creds: &ImapCredentials) -> Result, ImapError>; async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> { Ok(()) } async fn list_folders(&self, creds: &ImapCredentials) -> Result, ImapError>; async fn folder_status( &self, creds: &ImapCredentials, folder: &str, ) -> Result; async fn fetch_headers( &self, creds: &ImapCredentials, folder: &str, uid_range: &str, ) -> Result, ImapError>; async fn fetch_body( &self, creds: &ImapCredentials, folder: &str, uid: u32, ) -> Result; async fn fetch_raw_message( &self, _creds: &ImapCredentials, folder: &str, uid: u32, ) -> Result, ImapError> { Err(ImapError::MessageNotFound { uid, folder: folder.to_string(), }) } async fn add_flags( &self, creds: &ImapCredentials, folder: &str, uid: u32, flags: &[&str], ) -> Result<(), ImapError>; async fn remove_flags( &self, creds: &ImapCredentials, folder: &str, uid: u32, flags: &[&str], ) -> Result<(), ImapError>; async fn set_flags( &self, creds: &ImapCredentials, folder: &str, uid: u32, flags: &[&str], ) -> Result<(), ImapError>; async fn move_message( &self, creds: &ImapCredentials, from_folder: &str, uid: u32, to_folder: &str, ) -> Result<(), ImapError>; async fn copy_message( &self, creds: &ImapCredentials, from_folder: &str, uid: u32, to_folder: &str, ) -> Result<(), ImapError>; async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>; async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>; async fn expunge_message( &self, creds: &ImapCredentials, folder: &str, uid: u32, ) -> Result<(), ImapError>; async fn append_message( &self, creds: &ImapCredentials, folder: &str, message_bytes: &[u8], flags: &[&str], ) -> Result<(), ImapError>; 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>; async fn create_folder( &self, creds: &ImapCredentials, folder_name: &str, ) -> Result<(), ImapError>; async fn rename_folder( &self, creds: &ImapCredentials, from: &str, to: &str, ) -> Result<(), ImapError>; async fn delete_folder( &self, creds: &ImapCredentials, folder_name: &str, ) -> Result<(), ImapError>; async fn subscribe_folder( &self, creds: &ImapCredentials, folder_name: &str, subscribe: bool, ) -> Result<(), ImapError>; async fn fetch_uids_and_flags( &self, creds: &ImapCredentials, folder: &str, ) -> Result)>, ImapError>; /// `UID SEARCH ` in `folder`. Callers must build `criteria` /// from validated, quoted parts (see `routes::filters`). async fn search_uids( &self, creds: &ImapCredentials, folder: &str, criteria: &str, ) -> Result, ImapError>; async fn folder_status_extended( &self, creds: &ImapCredentials, folder: &str, ) -> Result; async fn folder_statuses_extended( &self, creds: &ImapCredentials, folders: &[String], ) -> Result, ImapError> { let mut statuses = Vec::with_capacity(folders.len()); for folder in folders { statuses.push(( folder.clone(), self.folder_status_extended(creds, folder).await?, )); } Ok(statuses) } async fn fetch_changed_flags( &self, creds: &ImapCredentials, folder: &str, since_modseq: u64, ) -> Result<(Vec<(u32, Vec)>, u64), ImapError>; async fn get_quota(&self, creds: &ImapCredentials) -> Result, ImapError>; async fn fetch_folder_size( &self, creds: &ImapCredentials, folder: &str, ) -> Result; async fn folder_keyword_support( &self, _creds: &ImapCredentials, _folder: &str, ) -> Result { Ok(false) } } #[derive(Default)] pub struct RealImapClient { pool: super::pool::ConnectionPool, } fn resolve_message_date( envelope_date: Option, internal_date: Option, ) -> Option { envelope_date .and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() || mail_parser::DateTime::parse_rfc822(trimmed).is_none() { None } else { Some(trimmed.to_string()) } }) .or_else(|| internal_date.filter(|value| !value.trim().is_empty())) } #[async_trait] impl ImapClient for RealImapClient { async fn capabilities(&self, creds: &ImapCredentials) -> Result, ImapError> { let mut session = self.pool.checkout(creds).await?; let caps = session.capabilities().await.map_err(map_imap_error)?; let mut values: Vec = caps .iter() .map(|cap| match cap { async_imap::types::Capability::Imap4rev1 => "IMAP4REV1".to_string(), async_imap::types::Capability::Auth(name) => format!("AUTH={name}"), async_imap::types::Capability::Atom(name) => name.to_string(), }) .collect(); values.sort_by_key(|value| value.to_ascii_uppercase()); values.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); session.recycle(); Ok(values) } async fn reset_connections(&self, creds: &ImapCredentials) -> Result<(), ImapError> { self.pool.clear_user(creds); Ok(()) } async fn folder_status( &self, creds: &ImapCredentials, folder: &str, ) -> Result { let mut session = self.pool.checkout(creds).await?; let mailbox = session.select(folder).await.map_err(|e| match &e { async_imap::error::Error::No(msg) if msg.to_lowercase().contains("not found") || msg.to_lowercase().contains("doesn't exist") || msg.to_lowercase().contains("does not exist") || msg.to_lowercase().contains("no such") => { ImapError::FolderNotFound(folder.to_string()) } _ => map_imap_error(e), })?; let uid_validity = mailbox.uid_validity.unwrap_or(0); let exists = mailbox.exists; let uid_next = mailbox.uid_next.unwrap_or(0); session.recycle(); Ok(FolderStatus { uid_validity, exists, uid_next, }) } async fn list_folders(&self, creds: &ImapCredentials) -> Result, ImapError> { let mut session = self.pool.checkout(creds).await?; let folders = { let names_stream = session .list(Some(""), Some("*")) .await .map_err(map_imap_error)?; let mut names_stream = std::pin::pin!(names_stream); let mut names = Vec::new(); while let Some(result) = names_stream.next().await { names.push(result.map_err(map_imap_error)?); } names .iter() .map(|n| ImapFolder { name: n.name().to_string(), delimiter: n.delimiter().map(|d| d.to_string()), attributes: n .attributes() .iter() .map(name_attribute_to_string) .collect(), }) .collect() }; session.recycle(); Ok(folders) } async fn fetch_headers( &self, creds: &ImapCredentials, folder: &str, uid_range: &str, ) -> Result, ImapError> { let mut session = self.pool.checkout(creds).await?; session.select(folder).await.map_err(|e| match &e { async_imap::error::Error::No(msg) if msg.to_lowercase().contains("not found") || msg.to_lowercase().contains("doesn't exist") || msg.to_lowercase().contains("does not exist") || msg.to_lowercase().contains("no such") => { ImapError::FolderNotFound(folder.to_string()) } _ => map_imap_error(e), })?; let fetches = { let mut fetch_stream = session .uid_fetch( uid_range, "(UID ENVELOPE INTERNALDATE FLAGS BODYSTRUCTURE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (Message-ID In-Reply-To References Content-Class x-ms-exchange-generated-message-class)])", ) .await .map_err(map_imap_error)?; let mut fetches = Vec::new(); while let Some(result) = fetch_stream.next().await { fetches.push(result.map_err(map_imap_error)?); } fetches }; session.recycle(); let folder = folder.to_owned(); tokio::task::spawn_blocking(move || { let mut headers = Vec::with_capacity(fetches.len()); for fetch in &fetches { let uid = match fetch.uid { Some(u) => u, None => continue, }; let raw_header_bytes = fetch.header(); let parsed_threading = raw_header_bytes .and_then(|raw| mail_parser::MessageParser::default().parse(raw)); let internal_date = fetch.internal_date().map(|value| value.to_rfc2822()); let (subject, from, to, cc, date) = if let Some(env) = fetch.envelope() { let subject = env.subject.as_ref().map(|b| decode_header_bytes(b)); let from: Vec = env .from .as_ref() .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) .unwrap_or_default(); let to: Vec = env .to .as_ref() .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) .unwrap_or_default(); let cc: Vec = env .cc .as_ref() .map(|addrs| addrs.iter().map(imap_address_to_email).collect()) .unwrap_or_default(); let envelope_date = env .date .as_ref() .and_then(|b| std::str::from_utf8(b).ok()) .map(str::to_string); let date = resolve_message_date(envelope_date, internal_date.clone()); (subject, from, to, cc, date) } else { tracing::warn!( uid = uid, folder = %folder, "ENVELOPE missing for message, headers will be empty until body is fetched" ); ( None, vec![], vec![], vec![], resolve_message_date(None, internal_date.clone()), ) }; let message_id = parsed_threading .as_ref() .and_then(|p| p.message_id().map(|s| format!("<{s}>"))); let in_reply_to = parsed_threading.as_ref().and_then(|p| { let val = p.in_reply_to(); val.as_text().map(|s| format!("<{s}>")) }); let references = parsed_threading.as_ref().and_then(|p| { let val = p.references(); val.as_text_list() .map(|list| { list.iter() .map(|s| format!("<{s}>")) .collect::>() .join(" ") }) .or_else(|| val.as_text().map(|s| format!("<{s}>"))) }); let reaction = raw_header_bytes.and_then(|raw| { let header_str = std::str::from_utf8(raw).ok()?; let lower = header_str.to_lowercase(); let is_reaction = lower.contains("content-class: activitynotification") || lower.contains("urn:content-class:reaction"); if !is_reaction { return None; } let subj = subject.as_deref().unwrap_or("").to_lowercase(); let emoji = match subj.trim() { s if s.contains("like") => "\u{1f44d}", s if s.contains("heart") || s.contains("love") => "\u{2764}\u{fe0f}", s if s.contains("laugh") => "\u{1f604}", s if s.contains("surprised") || s.contains("wow") => "\u{1f62e}", s if s.contains("sad") => "\u{1f622}", s if s.contains("angry") => "\u{1f620}", _ => "\u{1f44d}", }; Some(emoji.to_string()) }); let flags: Vec = fetch.flags().map(|f| flag_to_string(&f)).collect(); let has_attach = fetch .bodystructure() .map(|bs| has_attachments(bs)) .unwrap_or(false); let size = fetch.size.unwrap_or(0); headers.push(ImapMessageHeader { uid, subject, from, to, date, flags, has_attachments: has_attach, size, message_id, in_reply_to, references, cc, reaction, }); } headers }) .await .map_err(|e| ImapError::ProtocolError(format!("Header parsing task failed: {e}"))) } async fn fetch_body( &self, creds: &ImapCredentials, folder: &str, uid: u32, ) -> Result { let mut session = self.pool.checkout(creds).await?; session.select(folder).await.map_err(|e| match &e { async_imap::error::Error::No(msg) if msg.to_lowercase().contains("not found") || msg.to_lowercase().contains("doesn't exist") || msg.to_lowercase().contains("does not exist") || msg.to_lowercase().contains("no such") => { ImapError::FolderNotFound(folder.to_string()) } _ => map_imap_error(e), })?; let uid_str = uid.to_string(); let body = { let mut fetch_stream = session .uid_fetch(&uid_str, "(UID BODY[])") .await .map_err(map_imap_error)?; let mut fetches = Vec::new(); while let Some(result) = fetch_stream.next().await { fetches.push(result.map_err(map_imap_error)?); } let fetch = fetches.first().ok_or(ImapError::MessageNotFound { uid, folder: folder.to_string(), })?; let raw = fetch.body().ok_or_else(|| { ImapError::ProtocolError("BODY[] not returned by server".to_string()) })?; use mail_parser::MimeHeaders; let parsed = mail_parser::MessageParser::default() .parse(raw) .ok_or_else(|| { ImapError::ProtocolError("failed to parse RFC822 message".to_string()) })?; let text_plain: Option = parsed.body_text(0).map(|s| s.to_string()); let has_html_part = parsed.parts.iter().any(|part| { part.content_type() .is_some_and(|ct| ct.ctype() == "text" && ct.subtype() == Some("html")) }); let text_html: Option = if has_html_part { parsed.body_html(0).map(|s| s.to_string()) } else { None }; tracing::debug!( uid = uid, folder = %folder, total_parts = parsed.parts.len(), attachment_count = parsed.attachments().count(), has_text = text_plain.is_some(), has_html = text_html.is_some(), "fetch_body: parsed message structure" ); let mut attachments = Vec::new(); for attachment in parsed.attachments() { let filename: Option = attachment.attachment_name().map(|s| s.to_string()); let content_type: String = attachment.content_type().map_or_else( || "application/octet-stream".to_string(), |ct: &mail_parser::ContentType<'_>| { if let Some(subtype) = ct.subtype() { format!("{}/{}", ct.ctype(), subtype) } else { ct.ctype().to_string() } }, ); let content_id = attachment .content_id() .map(|s| s.trim_matches(|c| c == '<' || c == '>').to_string()); let data = attachment.contents().to_vec(); let size = data.len(); attachments.push(ImapAttachment { filename, content_type, size, data, content_id, }); } for part in parsed.parts.iter() { if part.content_id().is_none() { continue; } let is_text = part.content_type().is_some_and(|ct| ct.ctype() == "text"); if is_text { continue; } let cid = part .content_id() .unwrap() .trim_matches(|c| c == '<' || c == '>') .to_string(); if attachments .iter() .any(|a| a.content_id.as_deref() == Some(&cid)) { continue; } let content_type: String = part.content_type().map_or_else( || "application/octet-stream".to_string(), |ct: &mail_parser::ContentType<'_>| { if let Some(subtype) = ct.subtype() { format!("{}/{}", ct.ctype(), subtype) } else { ct.ctype().to_string() } }, ); let data = part.contents().to_vec(); let size = data.len(); attachments.push(ImapAttachment { filename: part.attachment_name().map(|s| s.to_string()), content_type, size, data, content_id: Some(cid), }); } let raw_str = String::from_utf8_lossy(raw); let raw_headers = raw_str .split_once("\r\n\r\n") .or_else(|| raw_str.split_once("\n\n")) .map_or_else(|| raw_str.to_string(), |(h, _)| h.to_string()); ImapMessageBody { uid, text_plain, text_html, attachments, raw_headers, } }; session.recycle(); Ok(body) } async fn fetch_raw_message( &self, creds: &ImapCredentials, folder: &str, uid: u32, ) -> Result, ImapError> { let mut session = self.pool.checkout(creds).await?; session.select(folder).await.map_err(map_imap_error)?; let uid_str = uid.to_string(); let raw = { let mut stream = session .uid_fetch(&uid_str, "(UID BODY.PEEK[])") .await .map_err(map_imap_error)?; let mut bytes = None; while let Some(result) = stream.next().await { let fetch = result.map_err(map_imap_error)?; if let Some(body) = fetch.body() { bytes = Some(body.to_vec()); break; } } bytes.ok_or(ImapError::MessageNotFound { uid, folder: folder.to_string(), })? }; session.recycle(); Ok(raw) } async fn add_flags( &self, creds: &ImapCredentials, folder: &str, uid: u32, flags: &[&str], ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(folder).await.map_err(map_imap_error)?; let uid_str = uid.to_string(); let flags_str = format!("+FLAGS ({})", flags.join(" ")); { let mut store_stream = session .uid_store(&uid_str, &flags_str) .await .map_err(map_imap_error)?; while let Some(result) = store_stream.next().await { result.map_err(map_imap_error)?; } } let _ = session.logout().await; Ok(()) } async fn remove_flags( &self, creds: &ImapCredentials, folder: &str, uid: u32, flags: &[&str], ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(folder).await.map_err(map_imap_error)?; let uid_str = uid.to_string(); let flags_str = format!("-FLAGS ({})", flags.join(" ")); { let mut store_stream = session .uid_store(&uid_str, &flags_str) .await .map_err(map_imap_error)?; while let Some(result) = store_stream.next().await { result.map_err(map_imap_error)?; } } let _ = session.logout().await; Ok(()) } async fn set_flags( &self, creds: &ImapCredentials, folder: &str, uid: u32, flags: &[&str], ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(folder).await.map_err(map_imap_error)?; let uid_str = uid.to_string(); let flags_str = format!("FLAGS ({})", flags.join(" ")); { let mut store_stream = session .uid_store(&uid_str, &flags_str) .await .map_err(map_imap_error)?; while let Some(result) = store_stream.next().await { result.map_err(map_imap_error)?; } } let _ = session.logout().await; Ok(()) } async fn move_message( &self, creds: &ImapCredentials, from_folder: &str, uid: u32, to_folder: &str, ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(from_folder).await.map_err(map_imap_error)?; let uid_str = uid.to_string(); match session.uid_mv(&uid_str, to_folder).await { Ok(()) => {} Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => { session .uid_copy(&uid_str, to_folder) .await .map_err(map_imap_error)?; { let mut store_stream = session .uid_store(&uid_str, "+FLAGS (\\Deleted)") .await .map_err(map_imap_error)?; while let Some(r) = store_stream.next().await { r.map_err(map_imap_error)?; } } { let expunge_stream = session.expunge().await.map_err(map_imap_error)?; let mut expunge_stream = std::pin::pin!(expunge_stream); while let Some(r) = expunge_stream.next().await { r.map_err(map_imap_error)?; } } } Err(e) => return Err(map_imap_error(e)), } let _ = session.logout().await; Ok(()) } async fn copy_message( &self, creds: &ImapCredentials, from_folder: &str, uid: u32, to_folder: &str, ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(from_folder).await.map_err(map_imap_error)?; session .uid_copy(&uid.to_string(), to_folder) .await .map_err(map_imap_error)?; let _ = session.logout().await; Ok(()) } async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(folder).await.map_err(map_imap_error)?; { let mut stream = session .uid_store("1:*", "+FLAGS (\\Seen)") .await .map_err(map_imap_error)?; while let Some(result) = stream.next().await { result.map_err(map_imap_error)?; } } let _ = session.logout().await; Ok(()) } async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(folder).await.map_err(map_imap_error)?; { let mut stream = session .uid_store("1:*", "+FLAGS (\\Deleted)") .await .map_err(map_imap_error)?; while let Some(result) = stream.next().await { result.map_err(map_imap_error)?; } } { let stream = session.expunge().await.map_err(map_imap_error)?; let mut stream = std::pin::pin!(stream); while let Some(result) = stream.next().await { result.map_err(map_imap_error)?; } } let _ = session.logout().await; Ok(()) } async fn expunge_message( &self, creds: &ImapCredentials, folder: &str, uid: u32, ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.select(folder).await.map_err(map_imap_error)?; let uid_str = uid.to_string(); { let mut store_stream = session .uid_store(&uid_str, "+FLAGS (\\Deleted)") .await .map_err(map_imap_error)?; while let Some(r) = store_stream.next().await { r.map_err(map_imap_error)?; } } let uid_expunge_ok = { match session.uid_expunge(&uid_str).await { Ok(stream) => { let mut stream = std::pin::pin!(stream); while let Some(r) = stream.next().await { r.map_err(map_imap_error)?; } true } Err(_) => false, } }; if !uid_expunge_ok { let stream = session.expunge().await.map_err(map_imap_error)?; let mut stream = std::pin::pin!(stream); while let Some(r) = stream.next().await { r.map_err(map_imap_error)?; } } let _ = session.logout().await; Ok(()) } async fn append_message( &self, creds: &ImapCredentials, folder: &str, message_bytes: &[u8], flags: &[&str], ) -> Result<(), ImapError> { let mut session = connect(creds).await?; let flags_str: Vec = flags.iter().map(|f| f.to_string()).collect(); let flags_joined = if flags_str.is_empty() { None } else { Some(format!("({})", flags_str.join(" "))) }; session .append(folder, flags_joined.as_deref(), None, message_bytes) .await .map_err(map_imap_error)?; let _ = session.logout().await; 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> { fn quote_search(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } if identity_header.is_empty() || !identity_header .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') || identity_value.contains('\r') || identity_value.contains('\n') { return Err(ImapError::ProtocolError( "Invalid draft identity header".to_string(), )); } let mut session = connect(creds).await?; let mailbox = session.select(folder).await.map_err(map_imap_error)?; let uid_validity = mailbox.uid_validity.unwrap_or(0); let uid_next_before = mailbox.uid_next.unwrap_or(0); let flags_str: Vec = flags.iter().map(|flag| flag.to_string()).collect(); let flags_joined = if flags_str.is_empty() { None } else { Some(format!("({})", flags_str.join(" "))) }; session .append(folder, flags_joined.as_deref(), None, message_bytes) .await .map_err(map_imap_error)?; let query = format!( "HEADER {} \"{}\"", identity_header, quote_search(identity_value) ); let matches = session.uid_search(query).await.map_err(map_imap_error)?; let mut candidates: Vec = matches .into_iter() .filter(|uid| uid_next_before == 0 || *uid >= uid_next_before) .collect(); candidates.sort_unstable(); let resolved = match candidates.as_slice() { [uid] => *uid, [] => { let _ = session.logout().await; return Err(ImapError::ProtocolError( "IMAP APPEND succeeded but the new draft UID could not be resolved".to_string(), )); } _ => { let _ = session.logout().await; return Err(ImapError::ProtocolError( "IMAP APPEND succeeded but more than one matching draft UID was returned" .to_string(), )); } }; let _ = session.logout().await; Ok((resolved, uid_validity)) } async fn create_folder( &self, creds: &ImapCredentials, folder_name: &str, ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.create(folder_name).await.map_err(map_imap_error)?; session .subscribe(folder_name) .await .map_err(map_imap_error)?; session.logout().await.ok(); Ok(()) } async fn rename_folder( &self, creds: &ImapCredentials, from: &str, to: &str, ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.rename(from, to).await.map_err(map_imap_error)?; session.logout().await.ok(); Ok(()) } async fn delete_folder( &self, creds: &ImapCredentials, folder_name: &str, ) -> Result<(), ImapError> { let mut session = connect(creds).await?; session.delete(folder_name).await.map_err(map_imap_error)?; session.logout().await.ok(); Ok(()) } async fn subscribe_folder( &self, creds: &ImapCredentials, folder_name: &str, subscribe: bool, ) -> Result<(), ImapError> { let mut session = connect(creds).await?; if subscribe { session .subscribe(folder_name) .await .map_err(map_imap_error)?; } else { session .unsubscribe(folder_name) .await .map_err(map_imap_error)?; } session.logout().await.ok(); Ok(()) } async fn fetch_uids_and_flags( &self, creds: &ImapCredentials, folder: &str, ) -> Result)>, ImapError> { let mut session = self.pool.checkout(creds).await?; session.select(folder).await.map_err(|e| match &e { async_imap::error::Error::No(msg) if msg.to_lowercase().contains("not found") || msg.to_lowercase().contains("doesn't exist") || msg.to_lowercase().contains("does not exist") || msg.to_lowercase().contains("no such") => { ImapError::FolderNotFound(folder.to_string()) } _ => map_imap_error(e), })?; let results = { let mut fetch_stream = session .uid_fetch("1:*", "(UID FLAGS)") .await .map_err(map_imap_error)?; let mut items = Vec::new(); while let Some(result) = fetch_stream.next().await { let fetch = result.map_err(map_imap_error)?; if let Some(uid) = fetch.uid { let flags: Vec = fetch.flags().map(|f| flag_to_string(&f)).collect(); items.push((uid, flags)); } } items }; session.recycle(); Ok(results) } async fn search_uids( &self, creds: &ImapCredentials, folder: &str, criteria: &str, ) -> Result, ImapError> { let mut session = self.pool.checkout(creds).await?; session.select(folder).await.map_err(|e| match &e { async_imap::error::Error::No(msg) if msg.to_lowercase().contains("not found") || msg.to_lowercase().contains("doesn't exist") || msg.to_lowercase().contains("does not exist") || msg.to_lowercase().contains("no such") => { ImapError::FolderNotFound(folder.to_string()) } _ => map_imap_error(e), })?; let matches = session.uid_search(criteria).await.map_err(map_imap_error)?; session.recycle(); let mut uids: Vec = matches.into_iter().collect(); uids.sort_unstable(); Ok(uids) } async fn folder_status_extended( &self, creds: &ImapCredentials, folder: &str, ) -> Result { let mut session = self.pool.checkout(creds).await?; let result = folder_status_extended_on_session(&mut session, folder).await; if result.is_ok() { session.recycle(); } result } async fn folder_statuses_extended( &self, creds: &ImapCredentials, folders: &[String], ) -> Result, ImapError> { let mut session = self.pool.checkout(creds).await?; let mut statuses = Vec::with_capacity(folders.len()); for folder in folders { match folder_status_extended_on_session(&mut session, folder).await { Ok(status) => statuses.push((folder.clone(), status)), Err(error) => { tracing::debug!(folder = %folder, error = %error, "Skipping folder STATUS refresh"); } } } session.recycle(); Ok(statuses) } async fn fetch_changed_flags( &self, creds: &ImapCredentials, folder: &str, since_modseq: u64, ) -> Result<(Vec<(u32, Vec)>, u64), ImapError> { let mut session = self.pool.checkout(creds).await?; let mailbox = session .select_condstore(folder) .await .map_err(map_imap_error)?; let new_modseq = mailbox.highest_modseq.unwrap_or(0); let items = { let mut fetch_stream = session .uid_fetch("1:*", format!("(UID FLAGS) (CHANGEDSINCE {since_modseq})")) .await .map_err(map_imap_error)?; let mut items = Vec::new(); while let Some(result) = fetch_stream.next().await { let fetch = result.map_err(map_imap_error)?; if let Some(uid) = fetch.uid { let flags: Vec = fetch.flags().map(|f| flag_to_string(&f)).collect(); items.push((uid, flags)); } } items }; session.recycle(); Ok((items, new_modseq)) } async fn get_quota(&self, creds: &ImapCredentials) -> Result, ImapError> { use async_imap::imap_proto::{Response, Status}; let mut session = self.pool.checkout(creds).await?; let req_id = session .run_command("GETQUOTAROOT INBOX") .await .map_err(map_imap_error)?; let (quota, reusable) = tokio::time::timeout(std::time::Duration::from_secs(15), async { let mut quota = None; while let Some(response) = session.read_response().await { let response = response.map_err(|e| ImapError::ConnectionFailed(e.to_string()))?; match response.parsed() { Response::Quota(q) => { for resource in &q.resources { if matches!( resource.name, async_imap::imap_proto::types::QuotaResourceName::Storage ) { quota = Some(MailboxQuota { usage_bytes: resource.usage * 1024, limit_bytes: resource.limit * 1024, }); } } } Response::Done { tag, status, .. } if *tag == req_id => { return match status { Status::Ok => Ok((quota, true)), Status::No | Status::Bad => Ok((None, true)), _ => Err(ImapError::ProtocolError( "unexpected GETQUOTAROOT status".into(), )), }; } Response::Data { status: Status::Bye, .. } => break, _ => {} } } Err(ImapError::ConnectionFailed( "connection lost during GETQUOTAROOT".into(), )) }) .await .map_err(|_| ImapError::ConnectionFailed("GETQUOTAROOT timed out after 15s".into()))??; if reusable { session.recycle(); } Ok(quota) } async fn fetch_folder_size( &self, creds: &ImapCredentials, folder: &str, ) -> Result { let mut session = self.pool.checkout(creds).await?; let mailbox = session.select(folder).await.map_err(map_imap_error)?; if mailbox.exists == 0 { session.recycle(); return Ok(0); } let mut total: u64 = 0; let fetch_result = tokio::time::timeout(std::time::Duration::from_secs(60), async { use async_imap::imap_proto::{Response, Status, types::AttributeValue}; let req_id = session .run_command("UID FETCH 1:* (RFC822.SIZE)") .await .map_err(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::Fetch(_, attributes) => { for attribute in attributes { if let AttributeValue::Rfc822Size(size) = attribute { total += u64::from(*size); } } } Response::Done { tag, status, .. } if *tag == req_id => { return if *status == Status::Ok { Ok(()) } else { Err(ImapError::ProtocolError( "folder-size FETCH rejected".into(), )) }; } Response::Data { status: Status::Bye, .. } => break, _ => {} } } Err(ImapError::ConnectionFailed( "connection lost during folder-size FETCH".into(), )) }) .await; match fetch_result { Ok(Err(e)) => { return Err(e); } Err(_) => { tracing::warn!(folder = %folder, "fetch_folder_size timed out after 60s"); return Err(ImapError::ConnectionFailed( "folder-size FETCH timed out after 60s".into(), )); } Ok(Ok(())) => session.recycle(), } Ok(total) } async fn folder_keyword_support( &self, creds: &ImapCredentials, folder: &str, ) -> Result { let mut session = self.pool.checkout(creds).await?; let mailbox = session.select(folder).await.map_err(map_imap_error)?; let supported = mailbox.permanent_flags.is_empty() || mailbox .permanent_flags .iter() .any(|flag| matches!(flag, async_imap::types::Flag::MayCreate)); session.recycle(); Ok(supported) } } #[cfg(test)] mod date_resolution_tests { use super::resolve_message_date; #[test] fn uses_internal_date_when_message_date_is_missing() { assert_eq!( resolve_message_date(None, Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string())), Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) ); } #[test] fn uses_internal_date_when_message_date_is_invalid() { assert_eq!( resolve_message_date( Some("not-a-date".to_string()), Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) ), Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) ); } #[test] fn keeps_valid_message_date() { assert_eq!( resolve_message_date( Some("Mon, 2 Nov 2026 10:00:00 +0100".to_string()), Some("Tue, 3 Nov 2026 14:38:39 +0100".to_string()) ), Some("Mon, 2 Nov 2026 10:00:00 +0100".to_string()) ); } }