v0.0.5
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+3 -80
View File
@@ -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