feat: implement server-side IMAP LIST and SEARCH filtering in Rust

Add server-side folder filtering using IMAP LIST patterns and enhance
message filtering to use IMAP SEARCH with keyword filters when available.

Key improvements:
- Add list_filtered_mailboxes() method using IMAP LIST with patterns
- Use server-side filtering instead of client-side folder filtering
- Enhance message search to use IMAP SEARCH for subject/sender keywords
- Add has_keyword_filters() method to MessageFilter
- Reduce network traffic by leveraging IMAP server capabilities
- Remove dependency on client-side filter_folders function

This achieves full feature parity with the updated Go implementation
and ensures both versions use IMAP standards optimally.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-08-03 14:29:49 +02:00
commit ee236db3c1
3 changed files with 140 additions and 15 deletions

View file

@ -77,6 +77,15 @@ pub struct MessageFilter {
pub recipient_keywords: Vec<String>, pub recipient_keywords: Vec<String>,
} }
impl MessageFilter {
/// Check if this filter has any keyword-based filters that can use IMAP SEARCH
pub fn has_keyword_filters(&self) -> bool {
!self.subject_keywords.is_empty() || !self.sender_keywords.is_empty()
// Note: recipient_keywords not included as IMAP SEARCH doesn't have a TO field search
// that works reliably across all IMAP servers
}
}
fn default_mode() -> String { fn default_mode() -> String {
"archive".to_string() "archive".to_string()
} }

View file

@ -236,6 +236,91 @@ impl ImapClient {
Ok(mailbox_names) Ok(mailbox_names)
} }
/// List mailboxes using IMAP LIST with server-side pattern filtering
pub async fn list_filtered_mailboxes(&mut self, filter: &crate::config::FolderFilter) -> Result<Vec<String>> {
let session = self.session.as_mut()
.ok_or_else(|| anyhow!("Not connected to IMAP server"))?;
let mut all_mailboxes = Vec::new();
let mut seen = std::collections::HashSet::new();
// If no include patterns, get all mailboxes
if filter.include.is_empty() {
return self.list_mailboxes().await;
}
// Use IMAP LIST with each include pattern for server-side filtering
for pattern in &filter.include {
log::debug!("Listing mailboxes with pattern: {}", pattern);
let mut mailboxes = session.list(Some(""), Some(pattern)).await
.map_err(|e| {
log::warn!("Failed to list mailboxes with pattern '{}': {:?}", pattern, e);
ImapError::Operation(format!("Failed to list mailboxes with pattern '{}': {:?}", pattern, e))
})?;
while let Some(mailbox_result) = mailboxes.next().await {
match mailbox_result {
Ok(mailbox) => {
let name = mailbox.name().to_string();
if seen.insert(name.clone()) {
all_mailboxes.push(name);
}
}
Err(e) => {
log::warn!("Error processing mailbox with pattern '{}': {:?}", pattern, e);
continue;
}
}
}
}
// Apply exclude filters client-side (IMAP LIST doesn't support exclusion)
if filter.exclude.is_empty() {
return Ok(all_mailboxes);
}
let filtered_mailboxes: Vec<String> = all_mailboxes
.into_iter()
.filter(|mailbox| {
!filter.exclude.iter().any(|exclude_pattern| {
self.matches_imap_pattern(exclude_pattern, mailbox)
})
})
.collect();
Ok(filtered_mailboxes)
}
/// Match IMAP-style patterns (simple * wildcard matching for exclude filters)
fn matches_imap_pattern(&self, pattern: &str, name: &str) -> bool {
// Handle exact match
if pattern == name {
return true;
}
// Handle simple prefix wildcard: "Work*" should match "Work/Projects"
if pattern.ends_with('*') && !pattern[..pattern.len()-1].contains('*') {
let prefix = &pattern[..pattern.len()-1];
return name.starts_with(prefix);
}
// Handle simple suffix wildcard: "*Temp" should match "Work/Temp"
if pattern.starts_with('*') && !pattern[1..].contains('*') {
let suffix = &pattern[1..];
return name.ends_with(suffix);
}
// Handle contains wildcard: "*Temp*" should match "Work/Temp/Archive"
if pattern.starts_with('*') && pattern.ends_with('*') {
let middle = &pattern[1..pattern.len()-1];
return name.contains(middle);
}
// For other patterns, fall back to basic string comparison
false
}
/// Select a mailbox /// Select a mailbox
pub async fn select_mailbox(&mut self, mailbox: &str) -> Result<MailboxInfo> { pub async fn select_mailbox(&mut self, mailbox: &str) -> Result<MailboxInfo> {
let session = self.session.as_mut() let session = self.session.as_mut()

View file

@ -5,7 +5,7 @@
use crate::config::{Config, MailSource, CommandLineArgs}; use crate::config::{Config, MailSource, CommandLineArgs};
use crate::couch::CouchClient; use crate::couch::CouchClient;
use crate::filters::{filter_folders, get_filter_summary, validate_folder_patterns}; use crate::filters::{get_filter_summary, validate_folder_patterns};
use crate::imap::{ImapClient, should_process_message}; use crate::imap::{ImapClient, should_process_message};
use crate::schemas::{SyncMetadata, generate_database_name}; use crate::schemas::{SyncMetadata, generate_database_name};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
@ -125,19 +125,27 @@ impl SyncCoordinator {
// Connect to IMAP server // Connect to IMAP server
let mut imap_client = ImapClient::connect(source.clone()).await?; let mut imap_client = ImapClient::connect(source.clone()).await?;
// Get list of available mailboxes // Use IMAP LIST with patterns for server-side filtering
let all_mailboxes = imap_client.list_mailboxes().await?; let filtered_mailboxes = imap_client.list_filtered_mailboxes(&source.folder_filter).await?;
info!("Found {} total mailboxes", all_mailboxes.len()); info!("Found {} matching mailboxes after server-side filtering", filtered_mailboxes.len());
// Apply folder filtering // For validation and summary, we still need the full list
let filtered_mailboxes = filter_folders(&all_mailboxes, &source.folder_filter); let all_mailboxes = if !source.folder_filter.include.is_empty() || !source.folder_filter.exclude.is_empty() {
let filter_summary = get_filter_summary(&all_mailboxes, &filtered_mailboxes, &source.folder_filter); // Only fetch all mailboxes if we have filters (for logging/validation)
info!("{}", filter_summary); imap_client.list_mailboxes().await.unwrap_or_else(|_| Vec::new())
} else {
filtered_mailboxes.clone()
};
// Validate folder patterns and show warnings if !all_mailboxes.is_empty() {
let warnings = validate_folder_patterns(&source.folder_filter, &all_mailboxes); let filter_summary = get_filter_summary(&all_mailboxes, &filtered_mailboxes, &source.folder_filter);
for warning in warnings { info!("{}", filter_summary);
warn!("{}", warning);
// Validate folder patterns and show warnings
let warnings = validate_folder_patterns(&source.folder_filter, &all_mailboxes);
for warning in warnings {
warn!("{}", warning);
}
} }
// Sync each filtered mailbox // Sync each filtered mailbox
@ -222,9 +230,32 @@ impl SyncCoordinator {
} }
}; };
// Search for messages // Search for messages using server-side IMAP SEARCH with keyword filtering when possible
let message_uids = imap_client.search_messages(since_date.as_ref()).await?; let message_uids = if source.message_filter.has_keyword_filters() {
info!(" Found {} messages to process", message_uids.len()); // Use advanced IMAP SEARCH with keyword filtering
let subject_keywords = if source.message_filter.subject_keywords.is_empty() {
None
} else {
Some(source.message_filter.subject_keywords.as_slice())
};
let from_keywords = if source.message_filter.sender_keywords.is_empty() {
None
} else {
Some(source.message_filter.sender_keywords.as_slice())
};
info!(" Using IMAP SEARCH with keyword filters");
imap_client.search_messages_advanced(
since_date.as_ref(),
None, // before_date
subject_keywords,
from_keywords,
).await?
} else {
// Use simple date-based search
imap_client.search_messages(since_date.as_ref()).await?
};
info!(" Found {} messages matching search criteria", message_uids.len());
// Handle sync mode - check for deleted messages // Handle sync mode - check for deleted messages
let mut messages_deleted = 0; let mut messages_deleted = 0;