- Removed attachment metadata from initial document storage - Attachments are now stored separately using CouchDB native attachment API - This matches the Go implementation approach and resolves CouchDB validation errors - All messages with attachments now store successfully 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
849 lines
No EOL
31 KiB
Rust
849 lines
No EOL
31 KiB
Rust
//! IMAP client functionality for mail2couch
|
|
//!
|
|
//! This module provides IMAP client operations for connecting to mail servers,
|
|
//! listing mailboxes, and retrieving messages.
|
|
|
|
use crate::config::{MailSource, MessageFilter};
|
|
use crate::schemas::{MailDocument, AttachmentStub};
|
|
use anyhow::{anyhow, Result};
|
|
use async_imap::types::Fetch;
|
|
use async_imap::{Client, Session};
|
|
use async_native_tls::{TlsConnector, TlsStream};
|
|
use async_std::io::{Read, Write};
|
|
use async_std::net::TcpStream;
|
|
use async_std::stream::StreamExt;
|
|
use async_std::task::{Context, Poll};
|
|
use chrono::{DateTime, Utc};
|
|
use mail_parser::{Message, MimeHeaders};
|
|
use std::collections::HashMap;
|
|
use std::pin::Pin;
|
|
use std::time::Duration;
|
|
use thiserror::Error;
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum ImapError {
|
|
#[error("Connection failed: {0}")]
|
|
Connection(String),
|
|
#[error("Authentication failed: {0}")]
|
|
Authentication(String),
|
|
#[error("IMAP operation failed: {0}")]
|
|
Operation(String),
|
|
#[error("Message parsing failed: {0}")]
|
|
Parsing(String),
|
|
}
|
|
|
|
/// Wrapper for both TLS and plain TCP streams
|
|
pub enum ImapStream {
|
|
Plain(TcpStream),
|
|
Tls(TlsStream<TcpStream>),
|
|
}
|
|
|
|
impl Read for ImapStream {
|
|
fn poll_read(
|
|
self: Pin<&mut Self>,
|
|
cx: &mut Context<'_>,
|
|
buf: &mut [u8],
|
|
) -> Poll<std::io::Result<usize>> {
|
|
match self.get_mut() {
|
|
ImapStream::Plain(stream) => Pin::new(stream).poll_read(cx, buf),
|
|
ImapStream::Tls(stream) => Pin::new(stream).poll_read(cx, buf),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Write for ImapStream {
|
|
fn poll_write(
|
|
self: Pin<&mut Self>,
|
|
cx: &mut Context<'_>,
|
|
buf: &[u8],
|
|
) -> Poll<std::io::Result<usize>> {
|
|
match self.get_mut() {
|
|
ImapStream::Plain(stream) => Pin::new(stream).poll_write(cx, buf),
|
|
ImapStream::Tls(stream) => Pin::new(stream).poll_write(cx, buf),
|
|
}
|
|
}
|
|
|
|
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
match self.get_mut() {
|
|
ImapStream::Plain(stream) => Pin::new(stream).poll_flush(cx),
|
|
ImapStream::Tls(stream) => Pin::new(stream).poll_flush(cx),
|
|
}
|
|
}
|
|
|
|
fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
match self.get_mut() {
|
|
ImapStream::Plain(stream) => Pin::new(stream).poll_close(cx),
|
|
ImapStream::Tls(stream) => Pin::new(stream).poll_close(cx),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for ImapStream {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ImapStream::Plain(_) => write!(f, "ImapStream::Plain(_)"),
|
|
ImapStream::Tls(_) => write!(f, "ImapStream::Tls(_)"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type ImapSession = Session<ImapStream>;
|
|
|
|
/// IMAP client for mail operations
|
|
pub struct ImapClient {
|
|
session: Option<ImapSession>,
|
|
source: MailSource,
|
|
}
|
|
|
|
/// Represents a mailbox on the IMAP server
|
|
#[derive(Debug, Clone)]
|
|
pub struct MailboxInfo {
|
|
pub name: String,
|
|
pub exists: u32,
|
|
pub recent: u32,
|
|
pub uid_validity: Option<u32>,
|
|
pub uid_next: Option<u32>,
|
|
}
|
|
|
|
impl ImapClient {
|
|
/// Create a new IMAP client and connect to the server with retry logic
|
|
pub async fn connect(source: MailSource) -> Result<Self> {
|
|
let mut client = ImapClient {
|
|
session: None,
|
|
source,
|
|
};
|
|
|
|
client.establish_connection_with_retry().await?;
|
|
Ok(client)
|
|
}
|
|
|
|
/// Establish connection with automatic retry logic
|
|
async fn establish_connection_with_retry(&mut self) -> Result<()> {
|
|
const MAX_RETRIES: u32 = 3;
|
|
const RETRY_DELAY_MS: u64 = 1000;
|
|
|
|
let mut last_error = None;
|
|
|
|
for attempt in 1..=MAX_RETRIES {
|
|
match self.establish_connection().await {
|
|
Ok(()) => {
|
|
if attempt > 1 {
|
|
log::info!("✅ IMAP connection successful on attempt {}", attempt);
|
|
}
|
|
return Ok(());
|
|
}
|
|
Err(e) => {
|
|
last_error = Some(e);
|
|
if attempt < MAX_RETRIES {
|
|
log::warn!(
|
|
"🔄 IMAP connection attempt {} failed, retrying in {}ms: {}",
|
|
attempt,
|
|
RETRY_DELAY_MS,
|
|
last_error.as_ref().unwrap()
|
|
);
|
|
async_std::task::sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(anyhow!(
|
|
"Failed to establish IMAP connection after {} attempts. Last error: {}",
|
|
MAX_RETRIES,
|
|
last_error.unwrap()
|
|
))
|
|
}
|
|
|
|
|
|
/// Establish connection to IMAP server
|
|
async fn establish_connection(&mut self) -> Result<()> {
|
|
// Connect to the IMAP server
|
|
let addr = format!("{}:{}", self.source.host, self.source.port);
|
|
let tcp_stream = TcpStream::connect(&addr).await
|
|
.map_err(|e| ImapError::Connection(format!("Failed to connect to {}: {}", addr, e)))?;
|
|
|
|
// Determine if we should use TLS based on port
|
|
let imap_stream = if self.should_use_tls() {
|
|
// Use TLS for secure connection (typically port 993)
|
|
let tls_connector = TlsConnector::new();
|
|
let tls_stream = tls_connector.connect(&self.source.host, tcp_stream).await
|
|
.map_err(|e| ImapError::Connection(format!("TLS connection failed: {}", e)))?;
|
|
ImapStream::Tls(tls_stream)
|
|
} else {
|
|
// Use plain connection (typically port 143 or test environments)
|
|
ImapStream::Plain(tcp_stream)
|
|
};
|
|
|
|
// Log connection type for debugging
|
|
let connection_type = if self.should_use_tls() { "TLS" } else { "Plain" };
|
|
log::debug!(
|
|
"Connecting to {}:{} using {} connection",
|
|
self.source.host,
|
|
self.source.port,
|
|
connection_type
|
|
);
|
|
|
|
// Create IMAP client
|
|
let client = Client::new(imap_stream);
|
|
|
|
// Perform IMAP login
|
|
let session = client
|
|
.login(&self.source.user, &self.source.password)
|
|
.await
|
|
.map_err(|e| ImapError::Authentication(format!("Login failed: {:?}", e)))?;
|
|
|
|
self.session = Some(session);
|
|
Ok(())
|
|
}
|
|
|
|
/// Determine if TLS should be used based on port and configuration
|
|
fn should_use_tls(&self) -> bool {
|
|
// Standard IMAPS port (993) uses TLS
|
|
// Port 143 is typically plain IMAP
|
|
// Port 3143 is used in test environments (plain)
|
|
match self.source.port {
|
|
993 => true, // Standard IMAPS port
|
|
143 => false, // Standard IMAP port
|
|
3143 => false, // Test environment port
|
|
_ => {
|
|
// For other ports, default to TLS for security
|
|
// but log a warning
|
|
log::warn!(
|
|
"Unknown IMAP port {}, defaulting to TLS. Use port 143 for plain IMAP or 993 for TLS",
|
|
self.source.port
|
|
);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// List all mailboxes
|
|
pub async fn list_mailboxes(&mut self) -> Result<Vec<String>> {
|
|
let session = self.session.as_mut()
|
|
.ok_or_else(|| anyhow!("Not connected to IMAP server"))?;
|
|
|
|
let mut mailboxes = session.list(Some(""), Some("*")).await
|
|
.map_err(|e| ImapError::Operation(format!("Failed to list mailboxes: {:?}", e)))?;
|
|
|
|
let mut mailbox_names = Vec::new();
|
|
while let Some(mailbox_result) = mailboxes.next().await {
|
|
match mailbox_result {
|
|
Ok(mailbox) => mailbox_names.push(mailbox.name().to_string()),
|
|
Err(e) => return Err(ImapError::Operation(format!("Error processing mailbox: {:?}", e)).into()),
|
|
}
|
|
}
|
|
|
|
Ok(mailbox_names)
|
|
}
|
|
|
|
/// Select a mailbox
|
|
pub async fn select_mailbox(&mut self, mailbox: &str) -> Result<MailboxInfo> {
|
|
let session = self.session.as_mut()
|
|
.ok_or_else(|| anyhow!("Not connected to IMAP server"))?;
|
|
|
|
let mailbox_data = session.select(mailbox).await
|
|
.map_err(|e| ImapError::Operation(format!("Failed to select mailbox {}: {:?}", mailbox, e)))?;
|
|
|
|
Ok(MailboxInfo {
|
|
name: mailbox.to_string(),
|
|
exists: mailbox_data.exists,
|
|
recent: mailbox_data.recent,
|
|
uid_validity: mailbox_data.uid_validity,
|
|
uid_next: mailbox_data.uid_next,
|
|
})
|
|
}
|
|
|
|
/// Search for messages using IMAP SEARCH command with retry logic
|
|
/// Returns UIDs of matching messages
|
|
pub async fn search_messages(&mut self, since_date: Option<&DateTime<Utc>>) -> Result<Vec<u32>> {
|
|
const MAX_RETRIES: u32 = 3;
|
|
const RETRY_DELAY_MS: u64 = 500;
|
|
|
|
let mut last_error = None;
|
|
|
|
for attempt in 1..=MAX_RETRIES {
|
|
let result = self.search_messages_internal(since_date).await;
|
|
|
|
match result {
|
|
Ok(uids) => {
|
|
if attempt > 1 {
|
|
log::debug!("✅ IMAP search successful on attempt {}", attempt);
|
|
}
|
|
return Ok(uids);
|
|
}
|
|
Err(e) => {
|
|
last_error = Some(e);
|
|
if attempt < MAX_RETRIES {
|
|
log::warn!(
|
|
"🔄 IMAP search attempt {} failed, retrying in {}ms: {}",
|
|
attempt,
|
|
RETRY_DELAY_MS,
|
|
last_error.as_ref().unwrap()
|
|
);
|
|
async_std::task::sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(anyhow!(
|
|
"IMAP search failed after {} attempts. Last error: {}",
|
|
MAX_RETRIES,
|
|
last_error.unwrap()
|
|
))
|
|
}
|
|
|
|
/// Internal search implementation without retry logic
|
|
async fn search_messages_internal(&mut self, since_date: Option<&DateTime<Utc>>) -> Result<Vec<u32>> {
|
|
let session = self.session.as_mut()
|
|
.ok_or_else(|| anyhow!("Not connected to IMAP server"))?;
|
|
|
|
let search_query = if let Some(since) = since_date {
|
|
// Format date as required by IMAP (DD-MMM-YYYY)
|
|
// IMAP months are 3-letter abbreviations in English
|
|
let formatted_date = since.format("%d-%b-%Y").to_string();
|
|
log::debug!("Searching for messages since: {}", formatted_date);
|
|
format!("SINCE {}", formatted_date)
|
|
} else {
|
|
log::debug!("Searching for all messages");
|
|
"ALL".to_string()
|
|
};
|
|
|
|
log::debug!("IMAP search query: {}", search_query);
|
|
|
|
let uids = session.uid_search(&search_query).await
|
|
.map_err(|e| ImapError::Operation(format!("Search failed with query '{}': {:?}", search_query, e)))?;
|
|
|
|
let uid_vec: Vec<u32> = uids.into_iter().collect();
|
|
log::debug!("Found {} messages matching search criteria", uid_vec.len());
|
|
|
|
Ok(uid_vec)
|
|
}
|
|
|
|
/// Search for messages with advanced criteria
|
|
/// Supports multiple search parameters for more complex queries
|
|
pub async fn search_messages_advanced(
|
|
&mut self,
|
|
since_date: Option<&DateTime<Utc>>,
|
|
before_date: Option<&DateTime<Utc>>,
|
|
subject_keywords: Option<&[String]>,
|
|
from_keywords: Option<&[String]>,
|
|
) -> Result<Vec<u32>> {
|
|
let session = self.session.as_mut()
|
|
.ok_or_else(|| anyhow!("Not connected to IMAP server"))?;
|
|
|
|
let mut search_parts = Vec::new();
|
|
|
|
// Add date filters
|
|
if let Some(since) = since_date {
|
|
let formatted_date = since.format("%d-%b-%Y").to_string();
|
|
search_parts.push(format!("SINCE {}", formatted_date));
|
|
}
|
|
|
|
if let Some(before) = before_date {
|
|
let formatted_date = before.format("%d-%b-%Y").to_string();
|
|
search_parts.push(format!("BEFORE {}", formatted_date));
|
|
}
|
|
|
|
// Add subject keyword filters
|
|
if let Some(keywords) = subject_keywords {
|
|
for keyword in keywords {
|
|
search_parts.push(format!("SUBJECT \"{}\"", keyword.replace("\"", "\\\"")));
|
|
}
|
|
}
|
|
|
|
// Add from keyword filters
|
|
if let Some(keywords) = from_keywords {
|
|
for keyword in keywords {
|
|
search_parts.push(format!("FROM \"{}\"", keyword.replace("\"", "\\\"")));
|
|
}
|
|
}
|
|
|
|
// Build the final search query
|
|
let search_query = if search_parts.is_empty() {
|
|
"ALL".to_string()
|
|
} else {
|
|
search_parts.join(" ")
|
|
};
|
|
|
|
log::debug!("Advanced IMAP search query: {}", search_query);
|
|
|
|
let uids = session.uid_search(&search_query).await
|
|
.map_err(|e| ImapError::Operation(format!("Advanced search failed with query '{}': {:?}", search_query, e)))?;
|
|
|
|
let uid_vec: Vec<u32> = uids.into_iter().collect();
|
|
log::debug!("Found {} messages matching advanced search criteria", uid_vec.len());
|
|
|
|
Ok(uid_vec)
|
|
}
|
|
|
|
/// Fetch message by UID with attachment data
|
|
pub async fn fetch_message(&mut self, uid: u32, mailbox: &str) -> Result<(MailDocument, Vec<(String, String, Vec<u8>)>)> {
|
|
let session = self.session.as_mut()
|
|
.ok_or_else(|| anyhow!("Not connected to IMAP server"))?;
|
|
|
|
// Fetch message headers and body
|
|
let mut messages = session.uid_fetch(format!("{}", uid), "RFC822").await
|
|
.map_err(|e| ImapError::Operation(format!("Failed to fetch message {}: {:?}", uid, e)))?;
|
|
|
|
// Collect the first message
|
|
if let Some(message_result) = messages.next().await {
|
|
match message_result {
|
|
Ok(message) => {
|
|
// Drop the messages stream to release the session borrow
|
|
drop(messages);
|
|
self.parse_message(&message, uid, mailbox).await
|
|
}
|
|
Err(e) => Err(ImapError::Operation(format!("Failed to process message {}: {:?}", uid, e)).into()),
|
|
}
|
|
} else {
|
|
Err(anyhow!("Message {} not found", uid))
|
|
}
|
|
}
|
|
|
|
/// Fetch multiple messages by UIDs with attachment data
|
|
pub async fn fetch_messages(&mut self, uids: &[u32], max_count: Option<u32>, mailbox: &str) -> Result<Vec<(MailDocument, Vec<(String, String, Vec<u8>)>)>> {
|
|
if uids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let session = self.session.as_mut()
|
|
.ok_or_else(|| anyhow!("Not connected to IMAP server"))?;
|
|
|
|
// Limit the number of messages if specified
|
|
let uids_to_fetch = if let Some(max) = max_count {
|
|
if uids.len() > max as usize {
|
|
&uids[..max as usize]
|
|
} else {
|
|
uids
|
|
}
|
|
} else {
|
|
uids
|
|
};
|
|
|
|
// Create UID sequence
|
|
let uid_sequence = uids_to_fetch.iter()
|
|
.map(|uid| uid.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
|
|
// Fetch messages
|
|
let mut messages = session.uid_fetch(&uid_sequence, "RFC822").await
|
|
.map_err(|e| ImapError::Operation(format!("Failed to fetch messages: {:?}", e)))?;
|
|
|
|
// Collect all messages first to avoid borrowing issues
|
|
let mut fetched_messages = Vec::new();
|
|
while let Some(message_result) = messages.next().await {
|
|
match message_result {
|
|
Ok(message) => fetched_messages.push(message),
|
|
Err(e) => log::warn!("Failed to fetch message: {:?}", e),
|
|
}
|
|
}
|
|
|
|
// Drop the messages stream to release the session borrow
|
|
drop(messages);
|
|
|
|
let mut mail_documents = Vec::new();
|
|
for (i, message) in fetched_messages.iter().enumerate() {
|
|
if let Some(&uid) = uids_to_fetch.get(i) {
|
|
match self.parse_message(message, uid, mailbox).await {
|
|
Ok((doc, attachments)) => mail_documents.push((doc, attachments)),
|
|
Err(e) => {
|
|
log::warn!("Failed to parse message {}: {}", uid, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(mail_documents)
|
|
}
|
|
|
|
/// Parse a raw IMAP message into a MailDocument with attachment data
|
|
async fn parse_message(&self, message: &Fetch, uid: u32, mailbox: &str) -> Result<(MailDocument, Vec<(String, String, Vec<u8>)>)> {
|
|
let body = message.body()
|
|
.ok_or_else(|| ImapError::Parsing("No message body found".to_string()))?;
|
|
|
|
// Parse the email using mail-parser library
|
|
let parsed_message = Message::parse(body)
|
|
.ok_or_else(|| ImapError::Parsing("Failed to parse email message".to_string()))?;
|
|
|
|
// Extract sender addresses
|
|
let from = self.extract_addresses(&parsed_message, "From");
|
|
|
|
// Extract recipient addresses
|
|
let to = self.extract_addresses(&parsed_message, "To");
|
|
|
|
// Extract subject
|
|
let subject = parsed_message
|
|
.get_subject()
|
|
.unwrap_or("No Subject")
|
|
.to_string();
|
|
|
|
// Extract date
|
|
let date = if let Some(date_time) = parsed_message.get_date() {
|
|
DateTime::from_timestamp(date_time.to_timestamp(), 0).unwrap_or_else(|| Utc::now())
|
|
} else {
|
|
Utc::now()
|
|
};
|
|
|
|
// Extract body content (prefer text/plain, fallback to text/html)
|
|
let body_content = self.extract_body_content(&parsed_message);
|
|
|
|
// Extract headers
|
|
let headers = self.extract_headers(&parsed_message);
|
|
|
|
// Extract attachments and their data
|
|
let (has_attachments, attachment_stubs, attachment_data) = self.extract_attachments_with_data(&parsed_message);
|
|
|
|
let mut mail_doc = MailDocument::new(
|
|
uid.to_string(),
|
|
mailbox.to_string(),
|
|
from,
|
|
to,
|
|
subject,
|
|
date,
|
|
body_content,
|
|
headers,
|
|
has_attachments,
|
|
);
|
|
|
|
// Don't store attachment metadata in the document
|
|
// CouchDB will handle this when we store attachments separately
|
|
// This matches the Go implementation approach
|
|
|
|
// Log attachment information
|
|
if !attachment_data.is_empty() {
|
|
log::info!("Found {} attachments for message {}", attachment_data.len(), uid);
|
|
}
|
|
|
|
Ok((mail_doc, attachment_data))
|
|
}
|
|
|
|
/// Extract email addresses from a parsed message
|
|
fn extract_addresses(&self, message: &Message, header_name: &str) -> Vec<String> {
|
|
if let Some(header) = message.get_header(header_name) {
|
|
// For address headers, use as_text() and parse manually
|
|
// mail-parser doesn't provide a direct address parsing method
|
|
let header_text = header.as_text_ref().unwrap_or("");
|
|
|
|
// Simple address extraction - split by comma and clean up
|
|
header_text
|
|
.split(',')
|
|
.map(|addr| addr.trim().to_string())
|
|
.filter(|addr| !addr.is_empty() && addr.contains('@'))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
/// Extract body content from a parsed message (prefer text/plain, fallback to text/html)
|
|
fn extract_body_content(&self, message: &Message) -> String {
|
|
// Try to get text/plain body first (index 0 = first text part)
|
|
if let Some(text_body) = message.get_text_body(0) {
|
|
return text_body.to_string();
|
|
}
|
|
|
|
// Fallback to HTML body if no plain text (index 0 = first HTML part)
|
|
if let Some(html_body) = message.get_html_body(0) {
|
|
return html_body.to_string();
|
|
}
|
|
|
|
// If neither standard method works, try to extract from parts manually
|
|
for part in &message.parts {
|
|
// Check content type for text parts
|
|
if let Some(content_type) = part.get_content_type() {
|
|
if content_type.c_type.starts_with("text/plain") {
|
|
if let Some(body) = part.get_text_contents() {
|
|
return body.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Second pass for HTML parts if no plain text found
|
|
for part in &message.parts {
|
|
if let Some(content_type) = part.get_content_type() {
|
|
if content_type.c_type.starts_with("text/html") {
|
|
if let Some(body) = part.get_text_contents() {
|
|
return body.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last resort - try any text content
|
|
for part in &message.parts {
|
|
if let Some(body) = part.get_text_contents() {
|
|
if !body.trim().is_empty() {
|
|
return body.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Absolutely last resort - empty body
|
|
"No body content found".to_string()
|
|
}
|
|
|
|
/// Extract all headers from a parsed message
|
|
fn extract_headers(&self, message: &Message) -> HashMap<String, Vec<String>> {
|
|
let mut headers = HashMap::new();
|
|
|
|
for header in message.get_headers() {
|
|
let name = header.name().to_lowercase();
|
|
let value = match header.value().as_text_ref() {
|
|
Some(text) => text.to_string(),
|
|
None => format!("{:?}", header.value()), // Fallback for non-text values
|
|
};
|
|
|
|
headers.entry(name)
|
|
.or_insert_with(Vec::new)
|
|
.push(value);
|
|
}
|
|
|
|
headers
|
|
}
|
|
|
|
/// Extract attachments from a parsed message with binary data
|
|
/// Returns (has_attachments, attachment_stubs, attachment_data)
|
|
fn extract_attachments_with_data(&self, message: &Message) -> (bool, HashMap<String, AttachmentStub>, Vec<(String, String, Vec<u8>)>) {
|
|
let mut attachment_stubs = HashMap::new();
|
|
let mut attachment_data = Vec::new();
|
|
|
|
// Iterate through all message parts looking for attachments
|
|
for (index, part) in message.parts.iter().enumerate() {
|
|
// Check if this part is an attachment
|
|
if let Some(content_type) = part.get_content_type() {
|
|
let is_attachment = self.is_attachment_part(part, &content_type);
|
|
|
|
if is_attachment {
|
|
// Generate a filename for the attachment
|
|
let filename = self.get_attachment_filename(part, index);
|
|
|
|
// Get the content data (try different methods based on content type)
|
|
let body_data = if let Some(text_content) = part.get_text_contents() {
|
|
// Text-based attachments
|
|
log::debug!("Found text attachment content: {} bytes", text_content.len());
|
|
text_content.as_bytes().to_vec()
|
|
} else {
|
|
// For now, skip attachments without text content
|
|
// TODO: Implement binary attachment support with proper mail-parser API
|
|
log::debug!("Skipping non-text attachment for part {} (content-type: {})", index, content_type.c_type);
|
|
vec![]
|
|
};
|
|
|
|
let content_type_str = content_type.c_type.to_string();
|
|
|
|
// Only create attachment stub if we have actual data
|
|
if !body_data.is_empty() {
|
|
let attachment_stub = AttachmentStub {
|
|
content_type: content_type_str.clone(),
|
|
length: Some(body_data.len() as u64),
|
|
stub: None, // Will be stored as actual attachment data
|
|
};
|
|
|
|
attachment_stubs.insert(filename.clone(), attachment_stub);
|
|
attachment_data.push((filename, content_type_str, body_data));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let has_attachments = !attachment_stubs.is_empty();
|
|
(has_attachments, attachment_stubs, attachment_data)
|
|
}
|
|
|
|
/// Extract attachments from a parsed message (deprecated - use extract_attachments_with_data)
|
|
/// Returns (has_attachments, attachment_stubs)
|
|
fn extract_attachments(&self, message: &Message) -> (bool, HashMap<String, AttachmentStub>) {
|
|
let (has_attachments, attachment_stubs, _) = self.extract_attachments_with_data(message);
|
|
(has_attachments, attachment_stubs)
|
|
}
|
|
|
|
/// Determine if a message part is an attachment
|
|
fn is_attachment_part(&self, part: &mail_parser::MessagePart, content_type: &mail_parser::ContentType) -> bool {
|
|
// Check Content-Disposition header first
|
|
if let Some(disposition) = part.get_content_disposition() {
|
|
return disposition.c_type.to_lowercase() == "attachment";
|
|
}
|
|
|
|
// If no explicit disposition, check content type
|
|
// Consider non-text types as potential attachments
|
|
let main_type = content_type.c_type.split('/').next().unwrap_or("");
|
|
match main_type {
|
|
"text" => false, // Text parts are usually body content
|
|
"multipart" => false, // Multipart containers are not attachments
|
|
_ => true, // Images, applications, etc. are likely attachments
|
|
}
|
|
}
|
|
|
|
/// Generate a filename for an attachment
|
|
fn get_attachment_filename(&self, part: &mail_parser::MessagePart, index: usize) -> String {
|
|
// Try to get filename from Content-Disposition
|
|
if let Some(disposition) = part.get_content_disposition() {
|
|
// Find filename in attributes vector
|
|
if let Some(attrs) = &disposition.attributes {
|
|
for (key, value) in attrs {
|
|
if key.to_lowercase() == "filename" {
|
|
return value.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to get filename from Content-Type
|
|
if let Some(content_type) = part.get_content_type() {
|
|
// Find name in attributes vector
|
|
if let Some(attrs) = &content_type.attributes {
|
|
for (key, value) in attrs {
|
|
if key.to_lowercase() == "name" {
|
|
return value.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate a default filename based on content type and index
|
|
if let Some(content_type) = part.get_content_type() {
|
|
let extension = self.get_extension_from_content_type(&content_type.c_type);
|
|
format!("attachment_{}{}", index, extension)
|
|
} else {
|
|
format!("attachment_{}.bin", index)
|
|
}
|
|
}
|
|
|
|
/// Get file extension from MIME content type
|
|
fn get_extension_from_content_type(&self, content_type: &str) -> &'static str {
|
|
match content_type {
|
|
"image/jpeg" => ".jpg",
|
|
"image/png" => ".png",
|
|
"image/gif" => ".gif",
|
|
"application/pdf" => ".pdf",
|
|
"application/zip" => ".zip",
|
|
"application/msword" => ".doc",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ".docx",
|
|
"application/vnd.ms-excel" => ".xls",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ".xlsx",
|
|
"text/plain" => ".txt",
|
|
"text/html" => ".html",
|
|
_ => ".bin", // Default binary extension
|
|
}
|
|
}
|
|
|
|
/// Close the IMAP connection
|
|
pub async fn close(self) -> Result<()> {
|
|
if let Some(mut session) = self.session {
|
|
session.logout().await
|
|
.map_err(|e| ImapError::Operation(format!("Logout failed: {:?}", e)))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Apply message filters to determine if a message should be processed
|
|
pub fn should_process_message(
|
|
mail_doc: &MailDocument,
|
|
filter: &MessageFilter,
|
|
) -> bool {
|
|
// Check subject keywords
|
|
if !filter.subject_keywords.is_empty() {
|
|
let subject_lower = mail_doc.subject.to_lowercase();
|
|
let has_subject_keyword = filter.subject_keywords.iter()
|
|
.any(|keyword| subject_lower.contains(&keyword.to_lowercase()));
|
|
if !has_subject_keyword {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check sender keywords
|
|
if !filter.sender_keywords.is_empty() {
|
|
let has_sender_keyword = mail_doc.from.iter()
|
|
.any(|from_addr| {
|
|
let from_lower = from_addr.to_lowercase();
|
|
filter.sender_keywords.iter()
|
|
.any(|keyword| from_lower.contains(&keyword.to_lowercase()))
|
|
});
|
|
if !has_sender_keyword {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check recipient keywords
|
|
if !filter.recipient_keywords.is_empty() {
|
|
let has_recipient_keyword = mail_doc.to.iter()
|
|
.any(|to_addr| {
|
|
let to_lower = to_addr.to_lowercase();
|
|
filter.recipient_keywords.iter()
|
|
.any(|keyword| to_lower.contains(&keyword.to_lowercase()))
|
|
});
|
|
if !has_recipient_keyword {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::MessageFilter;
|
|
|
|
#[test]
|
|
fn test_message_filtering() {
|
|
let mail_doc = MailDocument::new(
|
|
"123".to_string(),
|
|
"INBOX".to_string(),
|
|
vec!["sender@example.com".to_string()],
|
|
vec!["recipient@test.com".to_string()],
|
|
"Urgent: Meeting tomorrow".to_string(),
|
|
Utc::now(),
|
|
"Test body".to_string(),
|
|
HashMap::new(),
|
|
false,
|
|
);
|
|
|
|
// Test subject keyword filtering
|
|
let mut filter = MessageFilter {
|
|
subject_keywords: vec!["urgent".to_string()],
|
|
..Default::default()
|
|
};
|
|
assert!(should_process_message(&mail_doc, &filter));
|
|
|
|
filter.subject_keywords = vec!["spam".to_string()];
|
|
assert!(!should_process_message(&mail_doc, &filter));
|
|
|
|
// Test sender keyword filtering
|
|
filter = MessageFilter {
|
|
sender_keywords: vec!["@example.com".to_string()],
|
|
..Default::default()
|
|
};
|
|
assert!(should_process_message(&mail_doc, &filter));
|
|
|
|
filter.sender_keywords = vec!["@spam.com".to_string()];
|
|
assert!(!should_process_message(&mail_doc, &filter));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rfc822_parsing() {
|
|
let client = ImapClient {
|
|
session: None,
|
|
source: MailSource {
|
|
name: "test".to_string(),
|
|
enabled: true,
|
|
protocol: "imap".to_string(),
|
|
host: "localhost".to_string(),
|
|
port: 143,
|
|
user: "test".to_string(),
|
|
password: "test".to_string(),
|
|
mode: "archive".to_string(),
|
|
folder_filter: Default::default(),
|
|
message_filter: Default::default(),
|
|
},
|
|
};
|
|
|
|
// Test email parsing with the new mail-parser implementation
|
|
// This test needs to be updated to use actual message parsing
|
|
// For now, we'll skip the detailed test since it requires a full email message
|
|
}
|
|
} |