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
|
||||
|
||||
Reference in new issue
Block a user