1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
+3
-80
@@ -19,38 +19,24 @@ pub(crate) use super::connection::connect;
|
||||
#[path = "mock.rs"]
|
||||
pub mod mock;
|
||||
|
||||
/// Abstraction over IMAP operations.
|
||||
///
|
||||
/// Every method receives explicit connection parameters so that the trait
|
||||
/// keeps its API independent of connection management.
|
||||
///
|
||||
/// The `Send + Sync` bounds allow implementations to be shared across
|
||||
/// Tokio tasks and stored in `Arc`.
|
||||
#[allow(dead_code)]
|
||||
#[allow(clippy::double_must_use)]
|
||||
#[async_trait]
|
||||
pub trait ImapClient: Send + Sync {
|
||||
/// Return the capabilities advertised by the authenticated IMAP server.
|
||||
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError>;
|
||||
|
||||
/// Drop reusable request-path connections for this account. The default
|
||||
/// implementation is a no-op for test/alternate clients.
|
||||
async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all folders (mailboxes) on the server.
|
||||
async fn list_folders(&self, creds: &ImapCredentials) -> Result<Vec<ImapFolder>, ImapError>;
|
||||
|
||||
/// Perform a lightweight `SELECT` on a folder to get its status
|
||||
/// (UIDVALIDITY, EXISTS count, UIDNEXT) without fetching any messages.
|
||||
async fn folder_status(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
) -> Result<FolderStatus, ImapError>;
|
||||
|
||||
/// Fetch message headers (envelopes) for a range of UIDs in a folder.
|
||||
async fn fetch_headers(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -58,7 +44,6 @@ pub trait ImapClient: Send + Sync {
|
||||
uid_range: &str,
|
||||
) -> Result<Vec<ImapMessageHeader>, ImapError>;
|
||||
|
||||
/// Fetch the full body of a single message by UID.
|
||||
async fn fetch_body(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -66,7 +51,6 @@ pub trait ImapClient: Send + Sync {
|
||||
uid: u32,
|
||||
) -> Result<ImapMessageBody, ImapError>;
|
||||
|
||||
/// Fetch the complete RFC822 message bytes for source/download operations.
|
||||
async fn fetch_raw_message(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
@@ -79,7 +63,6 @@ pub trait ImapClient: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
/// Add flags to a message (IMAP +FLAGS).
|
||||
async fn add_flags(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -88,7 +71,6 @@ pub trait ImapClient: Send + Sync {
|
||||
flags: &[&str],
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Remove flags from a message (IMAP -FLAGS).
|
||||
async fn remove_flags(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -97,7 +79,6 @@ pub trait ImapClient: Send + Sync {
|
||||
flags: &[&str],
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Set (replace) the flags on a message.
|
||||
async fn set_flags(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -106,7 +87,6 @@ pub trait ImapClient: Send + Sync {
|
||||
flags: &[&str],
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Move a message from one folder to another.
|
||||
async fn move_message(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -115,7 +95,6 @@ pub trait ImapClient: Send + Sync {
|
||||
to_folder: &str,
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Copy a message to another folder without removing the source.
|
||||
async fn copy_message(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -124,13 +103,10 @@ pub trait ImapClient: Send + Sync {
|
||||
to_folder: &str,
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Mark all messages in a folder as read.
|
||||
async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
|
||||
|
||||
/// Permanently remove every message from a folder.
|
||||
async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
|
||||
|
||||
/// Permanently remove a message that has the `\Deleted` flag.
|
||||
async fn expunge_message(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -138,7 +114,6 @@ pub trait ImapClient: Send + Sync {
|
||||
uid: u32,
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Append a raw RFC822 message to a folder (e.g. saving sent mail to "Sent").
|
||||
async fn append_message(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -147,11 +122,6 @@ pub trait ImapClient: Send + Sync {
|
||||
flags: &[&str],
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Append a message and resolve the server-assigned UID without guessing.
|
||||
///
|
||||
/// `identity_header` and `identity_value` must uniquely identify this
|
||||
/// application-owned snapshot. Implementations only accept a UID at or
|
||||
/// above the mailbox UIDNEXT observed before APPEND.
|
||||
async fn append_message_resolved_uid(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -162,14 +132,12 @@ pub trait ImapClient: Send + Sync {
|
||||
identity_value: &str,
|
||||
) -> Result<(u32, u32), ImapError>;
|
||||
|
||||
/// Create a new folder (mailbox) and subscribe to it.
|
||||
async fn create_folder(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder_name: &str,
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Rename an existing folder.
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -177,14 +145,12 @@ pub trait ImapClient: Send + Sync {
|
||||
to: &str,
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Permanently delete a folder (mailbox).
|
||||
async fn delete_folder(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder_name: &str,
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Subscribe to or unsubscribe from a folder.
|
||||
async fn subscribe_folder(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -192,24 +158,18 @@ pub trait ImapClient: Send + Sync {
|
||||
subscribe: bool,
|
||||
) -> Result<(), ImapError>;
|
||||
|
||||
/// Fetch only UIDs and FLAGS for all messages in a folder.
|
||||
/// Used for periodic reconciliation to detect flag changes and deletions.
|
||||
async fn fetch_uids_and_flags(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
) -> Result<Vec<(u32, Vec<String>)>, ImapError>;
|
||||
|
||||
/// Lightweight STATUS command to get folder metadata without SELECT.
|
||||
/// Returns UIDVALIDITY, EXISTS, UIDNEXT, UNSEEN, and HIGHESTMODSEQ.
|
||||
async fn folder_status_extended(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
) -> Result<FolderStatusExtended, ImapError>;
|
||||
|
||||
/// Fetch only messages whose flags changed since `since_modseq` using CONDSTORE.
|
||||
/// Returns (vec of (uid, flags), new_highest_modseq).
|
||||
async fn fetch_changed_flags(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
@@ -217,19 +177,14 @@ pub trait ImapClient: Send + Sync {
|
||||
since_modseq: u64,
|
||||
) -> Result<(Vec<(u32, Vec<String>)>, u64), ImapError>;
|
||||
|
||||
/// Fetch mailbox quota via IMAP GETQUOTAROOT.
|
||||
/// Returns `None` if the server doesn't support quotas.
|
||||
async fn get_quota(&self, creds: &ImapCredentials) -> Result<Option<MailboxQuota>, ImapError>;
|
||||
|
||||
/// Fetch the total size of all messages in a folder via UID FETCH 1:* (RFC822.SIZE).
|
||||
async fn fetch_folder_size(
|
||||
&self,
|
||||
creds: &ImapCredentials,
|
||||
folder: &str,
|
||||
) -> Result<u64, ImapError>;
|
||||
|
||||
/// Return whether the selected mailbox allows creating persistent custom keywords.
|
||||
/// An absent PERMANENTFLAGS response is treated according to RFC 3501 as permissive.
|
||||
async fn folder_keyword_support(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
@@ -239,21 +194,11 @@ pub trait ImapClient: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real implementation backed by async-imap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Production IMAP client that uses `async-imap` and `mail-parser`.
|
||||
///
|
||||
/// Read operations reuse bounded, exclusively leased authenticated sessions.
|
||||
/// Writes retain dedicated connections for now.
|
||||
#[derive(Default)]
|
||||
pub struct RealImapClient {
|
||||
pool: super::pool::ConnectionPool,
|
||||
}
|
||||
|
||||
// ---- Trait implementation -------------------------------------------------
|
||||
|
||||
#[async_trait]
|
||||
impl ImapClient for RealImapClient {
|
||||
async fn capabilities(&self, creds: &ImapCredentials) -> Result<Vec<String>, ImapError> {
|
||||
@@ -363,9 +308,6 @@ impl ImapClient for RealImapClient {
|
||||
})?;
|
||||
|
||||
let fetches = {
|
||||
// Fetch ENVELOPE, FLAGS, BODYSTRUCTURE, RFC822.SIZE, and threading headers.
|
||||
// We only fetch Message-ID, In-Reply-To, and References (a few bytes per message)
|
||||
// rather than full raw headers, to keep bulk syncs lightweight.
|
||||
let mut fetch_stream = session
|
||||
.uid_fetch(
|
||||
uid_range,
|
||||
@@ -391,7 +333,6 @@ impl ImapClient for RealImapClient {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Parse threading headers from the small HEADER.FIELDS response.
|
||||
let raw_header_bytes = fetch.header();
|
||||
let parsed_threading = raw_header_bytes
|
||||
.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
|
||||
@@ -429,9 +370,6 @@ impl ImapClient for RealImapClient {
|
||||
|
||||
(subject, from, to, cc, date)
|
||||
} else {
|
||||
// No envelope — we can't fill subject/from/to/date from the
|
||||
// small threading-only header fetch, so leave them empty.
|
||||
// They'll be populated when the user opens the message body.
|
||||
tracing::warn!(
|
||||
uid = uid,
|
||||
folder = %folder,
|
||||
@@ -440,7 +378,6 @@ impl ImapClient for RealImapClient {
|
||||
(None, vec![], vec![], vec![], None)
|
||||
};
|
||||
|
||||
// Extract threading headers from the small HEADER.FIELDS response.
|
||||
let message_id = parsed_threading
|
||||
.as_ref()
|
||||
.and_then(|p| p.message_id().map(|s| format!("<{s}>")));
|
||||
@@ -460,7 +397,6 @@ impl ImapClient for RealImapClient {
|
||||
.or_else(|| val.as_text().map(|s| format!("<{s}>")))
|
||||
});
|
||||
|
||||
// Detect Outlook/Exchange reaction emails from headers.
|
||||
let reaction = raw_header_bytes.and_then(|raw| {
|
||||
let header_str = std::str::from_utf8(raw).ok()?;
|
||||
let lower = header_str.to_lowercase();
|
||||
@@ -587,7 +523,6 @@ impl ImapClient for RealImapClient {
|
||||
|
||||
let mut attachments = Vec::new();
|
||||
|
||||
// Collect explicit attachments.
|
||||
for attachment in parsed.attachments() {
|
||||
let filename: Option<String> = attachment.attachment_name().map(|s| s.to_string());
|
||||
let content_type: String = attachment.content_type().map_or_else(
|
||||
@@ -614,13 +549,11 @@ impl ImapClient for RealImapClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Also collect inline parts with Content-ID (e.g. embedded images
|
||||
// referenced via cid: URLs in the HTML body).
|
||||
for part in parsed.parts.iter() {
|
||||
if part.content_id().is_none() {
|
||||
continue;
|
||||
}
|
||||
// Skip if this is a text/html or text/plain body part.
|
||||
|
||||
let is_text = part.content_type().is_some_and(|ct| ct.ctype() == "text");
|
||||
if is_text {
|
||||
continue;
|
||||
@@ -630,7 +563,7 @@ impl ImapClient for RealImapClient {
|
||||
.unwrap()
|
||||
.trim_matches(|c| c == '<' || c == '>')
|
||||
.to_string();
|
||||
// Skip if we already captured this part via attachments().
|
||||
|
||||
if attachments
|
||||
.iter()
|
||||
.any(|a| a.content_id.as_deref() == Some(&cid))
|
||||
@@ -658,7 +591,6 @@ impl ImapClient for RealImapClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Extract raw headers from the RFC 822 message.
|
||||
let raw_str = String::from_utf8_lossy(raw);
|
||||
let raw_headers = raw_str
|
||||
.split_once("\r\n\r\n")
|
||||
@@ -780,7 +712,6 @@ impl ImapClient for RealImapClient {
|
||||
.await
|
||||
.map_err(map_imap_error)?;
|
||||
|
||||
// Consume the stream to completion so the command finishes.
|
||||
while let Some(result) = store_stream.next().await {
|
||||
result.map_err(map_imap_error)?;
|
||||
}
|
||||
@@ -803,12 +734,9 @@ impl ImapClient for RealImapClient {
|
||||
|
||||
let uid_str = uid.to_string();
|
||||
|
||||
// Try UID MOVE first; fall back to COPY + DELETE + EXPUNGE if the
|
||||
// server does not support the MOVE extension.
|
||||
match session.uid_mv(&uid_str, to_folder).await {
|
||||
Ok(()) => {}
|
||||
Err(async_imap::error::Error::No(_) | async_imap::error::Error::Bad(_)) => {
|
||||
// Fallback: COPY, then flag \Deleted, then EXPUNGE.
|
||||
session
|
||||
.uid_copy(&uid_str, to_folder)
|
||||
.await
|
||||
@@ -907,7 +835,6 @@ impl ImapClient for RealImapClient {
|
||||
|
||||
let uid_str = uid.to_string();
|
||||
|
||||
// Mark the message as \Deleted.
|
||||
{
|
||||
let mut store_stream = session
|
||||
.uid_store(&uid_str, "+FLAGS (\\Deleted)")
|
||||
@@ -918,7 +845,6 @@ impl ImapClient for RealImapClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Try UID EXPUNGE for precision; fall back to EXPUNGE.
|
||||
let uid_expunge_ok = {
|
||||
match session.uid_expunge(&uid_str).await {
|
||||
Ok(stream) => {
|
||||
@@ -1214,8 +1140,7 @@ impl ImapClient for RealImapClient {
|
||||
.run_command("GETQUOTAROOT INBOX")
|
||||
.await
|
||||
.map_err(map_imap_error)?;
|
||||
// Matching tagged OK/NO/BAD completes the command. Transport failures
|
||||
// and incomplete replies still discard the session.
|
||||
|
||||
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 {
|
||||
@@ -1277,8 +1202,6 @@ impl ImapClient for RealImapClient {
|
||||
|
||||
let mut total: u64 = 0;
|
||||
|
||||
// Require a tagged completion: async-imap's FETCH stream hides tagged
|
||||
// NO/BAD and can end at EOF, which is unsafe when returning a pooled session.
|
||||
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
|
||||
|
||||
+1
-20
@@ -1,7 +1,6 @@
|
||||
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>>,
|
||||
@@ -16,14 +15,6 @@ pub(super) fn connection_count(creds: &ImapCredentials) -> usize {
|
||||
.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> {
|
||||
@@ -36,7 +27,7 @@ pub(crate) async fn connect(
|
||||
.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()))?
|
||||
@@ -68,10 +59,6 @@ pub(crate) async fn connect(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -119,7 +106,6 @@ async fn maybe_enable_utf8_accept(
|
||||
))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -131,7 +117,6 @@ pub(crate) fn classify_login_error(err: async_imap::error::Error) -> ImapError {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}")),
|
||||
@@ -143,10 +128,6 @@ pub(crate) fn map_imap_error(err: async_imap::error::Error) -> ImapError {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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>),
|
||||
|
||||
+4
-6
@@ -1,17 +1,15 @@
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
+10
-47
@@ -1,11 +1,6 @@
|
||||
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>>,
|
||||
@@ -15,12 +10,11 @@ pub struct MockImapClient {
|
||||
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()),
|
||||
@@ -35,47 +29,39 @@ impl MockImapClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
@@ -127,7 +113,7 @@ impl ImapClient for MockImapClient {
|
||||
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;
|
||||
@@ -255,11 +241,7 @@ impl ImapClient for MockImapClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn empty_folder(
|
||||
&self,
|
||||
_creds: &ImapCredentials,
|
||||
_folder: &str,
|
||||
) -> Result<(), ImapError> {
|
||||
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));
|
||||
}
|
||||
@@ -390,7 +372,7 @@ impl ImapClient for MockImapClient {
|
||||
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;
|
||||
@@ -412,7 +394,7 @@ impl ImapClient for MockImapClient {
|
||||
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();
|
||||
@@ -438,15 +420,10 @@ impl ImapClient for MockImapClient {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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(),
|
||||
@@ -636,7 +613,7 @@ mod tests {
|
||||
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}"
|
||||
@@ -760,19 +737,6 @@ mod tests {
|
||||
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()?;
|
||||
@@ -793,7 +757,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Run manually: cargo test real_imap_list_folders -- --ignored
|
||||
#[ignore]
|
||||
async fn real_imap_list_folders() {
|
||||
let creds = real_creds().expect("TEST_IMAP_* env vars required");
|
||||
let client = RealImapClient::default();
|
||||
@@ -807,24 +771,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Run manually: cargo test real_imap_fetch_headers -- --ignored
|
||||
#[ignore]
|
||||
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
|
||||
#[ignore]
|
||||
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();
|
||||
|
||||
+7
-25
@@ -1,7 +1,5 @@
|
||||
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 {
|
||||
@@ -21,8 +19,6 @@ pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helper: convert Flag to string ---------------------------------------
|
||||
|
||||
pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String {
|
||||
use async_imap::types::Flag;
|
||||
match flag {
|
||||
@@ -37,11 +33,7 @@ pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> 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();
|
||||
}
|
||||
@@ -50,11 +42,10 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
||||
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..];
|
||||
@@ -77,15 +68,13 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
||||
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();
|
||||
}
|
||||
@@ -94,7 +83,6 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed encoded word — keep the `=?` and move past it.
|
||||
result.push_str("=?");
|
||||
remaining = after_start;
|
||||
}
|
||||
@@ -103,7 +91,6 @@ pub(crate) fn decode_rfc2047(input: &str) -> String {
|
||||
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();
|
||||
@@ -124,20 +111,19 @@ pub(crate) fn decode_quoted_printable_header(input: &str) -> Option<Vec<u8>> {
|
||||
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 {
|
||||
pub(crate) fn imap_address_to_email(
|
||||
addr: &async_imap::imap_proto::types::Address<'_>,
|
||||
) -> EmailAddress {
|
||||
let name = addr
|
||||
.name
|
||||
.as_ref()
|
||||
@@ -164,24 +150,20 @@ pub(crate) fn imap_address_to_email(addr: &async_imap::imap_proto::types::Addres
|
||||
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");
|
||||
}
|
||||
|
||||
+5
-15
@@ -1,4 +1,3 @@
|
||||
//! Request-path sessions only; IDLE owns a separate connection.
|
||||
use super::{
|
||||
connection::{ImapStream, connect},
|
||||
error::ImapError,
|
||||
@@ -19,7 +18,6 @@ use tokio::{
|
||||
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
|
||||
}
|
||||
@@ -47,7 +45,6 @@ struct UserPool {
|
||||
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 [
|
||||
@@ -64,8 +61,6 @@ pub(super) fn identity(creds: &ImapCredentials) -> [u8; 32] {
|
||||
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
|
||||
@@ -98,7 +93,7 @@ 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
|
||||
});
|
||||
@@ -106,8 +101,6 @@ impl PoolInner {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -139,7 +132,7 @@ impl ConnectionPool {
|
||||
})
|
||||
})
|
||||
.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
|
||||
@@ -189,7 +182,7 @@ impl ConnectionPool {
|
||||
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()))??;
|
||||
@@ -202,8 +195,6 @@ impl ConnectionPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
@@ -299,7 +290,6 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
if line.contains(" UID FETCH ") && selected == "stalled" {
|
||||
// No completion. The caller must discard on cancellation/timeout.
|
||||
continue;
|
||||
}
|
||||
let response = if line.contains(" GETQUOTAROOT ") {
|
||||
@@ -402,7 +392,7 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS);
|
||||
drop(leases.pop()); // Unfinished work must not be reused.
|
||||
drop(leases.pop());
|
||||
let lease = pool.checkout(&creds).await.unwrap();
|
||||
assert_eq!(logins.load(Ordering::SeqCst), MAX_SESSIONS + 1);
|
||||
lease.recycle();
|
||||
@@ -478,7 +468,7 @@ mod tests {
|
||||
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(),
|
||||
|
||||
+26
-44
@@ -1,88 +1,76 @@
|
||||
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,
|
||||
@@ -93,28 +81,22 @@ pub struct ImapCredentials {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user