feat: add comprehensive Rust implementation with feature parity

This commit completes the Rust implementation of mail2couch with full feature
parity to the Go version, including:

- Complete IMAP client with TLS support and retry logic
- Advanced email parsing with MIME multipart support using mail-parser
- Email attachment extraction and CouchDB storage
- Sync mode implementation with deleted message handling
- Enhanced error handling and retry mechanisms
- Identical command-line interface with bash completion
- Test configurations for both implementations

The Rust implementation now provides:
- Memory safety and type safety guarantees
- Modern async/await patterns with tokio/async-std
- Comprehensive error handling with anyhow/thiserror
- Structured logging and progress reporting
- Performance optimizations and retry logic

Test configurations created:
- rust/config-test-rust.json - Rust implementation test config
- go/config-test-go.json - Go implementation test config
- test-config-comparison.md - Detailed comparison documentation
- test-both-implementations.sh - Automated testing script

Both implementations can now be tested side-by-side with identical
configurations to validate feature parity and performance.

🤖 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-02 20:27:14 +02:00
commit 7b98efe06b
8 changed files with 1086 additions and 100 deletions

View file

@ -8,6 +8,7 @@ use crate::schemas::{MailDocument, SyncMetadata};
use anyhow::{anyhow, Result};
use reqwest::{Client, StatusCode};
use serde_json::Value;
use std::time::Duration;
use thiserror::Error;
#[derive(Error, Debug)]
@ -42,6 +43,62 @@ pub struct CouchResponse {
}
impl CouchClient {
/// Generic retry helper for CouchDB operations
async fn retry_operation<F, Fut, T>(&self, operation_name: &str, operation: F) -> Result<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
const MAX_RETRIES: u32 = 3;
const RETRY_DELAY_MS: u64 = 1000;
let mut last_error = None;
for attempt in 1..=MAX_RETRIES {
match operation().await {
Ok(result) => {
if attempt > 1 {
log::debug!("✅ CouchDB {} successful on attempt {}", operation_name, attempt);
}
return Ok(result);
}
Err(e) => {
// Check if this is a retryable error
let is_retryable = match &e.downcast_ref::<CouchError>() {
Some(CouchError::Http(_)) => true, // Network errors are retryable
Some(CouchError::CouchDb { status, .. }) => {
// Retry on server errors (5xx) but not client errors (4xx)
*status >= 500
}
_ => false, // Other errors are not retryable
};
last_error = Some(e);
if is_retryable && attempt < MAX_RETRIES {
log::warn!(
"🔄 CouchDB {} attempt {} failed, retrying in {}ms: {}",
operation_name,
attempt,
RETRY_DELAY_MS,
last_error.as_ref().unwrap()
);
tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
} else {
break;
}
}
}
}
Err(anyhow!(
"CouchDB {} failed after {} attempts. Last error: {}",
operation_name,
MAX_RETRIES,
last_error.unwrap()
))
}
/// Create a new CouchDB client
pub fn new(config: &CouchDbConfig) -> Result<Self> {
let client = Client::new();
@ -115,22 +172,68 @@ impl CouchClient {
Ok(response.status().is_success())
}
/// Store a mail document in CouchDB
/// Store a mail document in CouchDB with optional attachments and retry logic
pub async fn store_mail_document(&self, db_name: &str, mut document: MailDocument) -> Result<String> {
// Set the document ID if not already set
if document.id.is_none() {
document.set_id();
}
let doc_id = document.id.as_ref().unwrap();
let doc_id = document.id.as_ref().unwrap().clone();
// Check if document already exists to avoid duplicates
if self.document_exists(db_name, doc_id).await? {
return Ok(doc_id.clone());
if self.document_exists(db_name, &doc_id).await? {
return Ok(doc_id);
}
let url = format!("{}/{}/{}", self.base_url, db_name, doc_id);
let mut request = self.client.put(&url).json(&document);
self.retry_operation("store_mail_document", || async {
let url = format!("{}/{}/{}", self.base_url, db_name, doc_id);
let mut request = self.client.put(&url).json(&document);
if let Some((username, password)) = &self.auth {
request = request.basic_auth(username, Some(password));
}
let response = request.send().await
.map_err(|e| CouchError::Http(e))?;
match response.status() {
StatusCode::CREATED | StatusCode::ACCEPTED => {
let couch_response: CouchResponse = response.json().await
.map_err(|e| CouchError::Http(e))?;
Ok(couch_response.id.unwrap_or_else(|| doc_id.clone()))
}
status => {
let error_text = response.text().await
.unwrap_or_else(|_| "Failed to read error response".to_string());
Err(CouchError::CouchDb {
status: status.as_u16(),
message: error_text,
}.into())
}
}
}).await
}
/// Store an attachment for a document in CouchDB
pub async fn store_attachment(
&self,
db_name: &str,
doc_id: &str,
attachment_name: &str,
content_type: &str,
data: &[u8],
) -> Result<String> {
// First get the current document revision
let doc_response = self.get_document_rev(db_name, doc_id).await?;
let rev = doc_response.ok_or_else(|| anyhow!("Document {} not found", doc_id))?;
// Upload the attachment
let url = format!("{}/{}/{}/{}?rev={}", self.base_url, db_name, doc_id, attachment_name, rev);
let mut request = self.client
.put(&url)
.header("Content-Type", content_type)
.body(data.to_vec());
if let Some((username, password)) = &self.auth {
request = request.basic_auth(username, Some(password));
@ -141,11 +244,35 @@ impl CouchClient {
match response.status() {
StatusCode::CREATED | StatusCode::ACCEPTED => {
let couch_response: CouchResponse = response.json().await?;
Ok(couch_response.id.unwrap_or_else(|| doc_id.clone()))
Ok(couch_response.rev.unwrap_or_else(|| rev))
}
status => {
let error_text = response.text().await?;
Err(anyhow!("Failed to store document {}: {} - {}", doc_id, status, error_text))
Err(anyhow!("Failed to store attachment {}: {} - {}", attachment_name, status, error_text))
}
}
}
/// Get document revision
async fn get_document_rev(&self, db_name: &str, doc_id: &str) -> Result<Option<String>> {
let url = format!("{}/{}/{}", self.base_url, db_name, doc_id);
let mut request = self.client.get(&url);
if let Some((username, password)) = &self.auth {
request = request.basic_auth(username, Some(password));
}
let response = request.send().await?;
match response.status() {
StatusCode::OK => {
let doc: Value = response.json().await?;
Ok(doc["_rev"].as_str().map(|s| s.to_string()))
}
StatusCode::NOT_FOUND => Ok(None),
status => {
let error_text = response.text().await?;
Err(anyhow!("Failed to get document {}: {} - {}", doc_id, status, error_text))
}
}
}
@ -244,6 +371,46 @@ impl CouchClient {
}
}
/// Get all message UIDs for a specific mailbox from CouchDB
pub async fn get_mailbox_uids(&self, db_name: &str, mailbox: &str) -> Result<Vec<u32>> {
let url = format!("{}/{}/_all_docs", self.base_url, db_name);
let query_params = [
("startkey", format!("\"{}\"", mailbox)),
("endkey", format!("\"{}\\ufff0\"", mailbox)), // High Unicode character for range end
("include_docs", "false".to_string()),
];
let mut request = self.client.get(&url).query(&query_params);
if let Some((username, password)) = &self.auth {
request = request.basic_auth(username, Some(password));
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(anyhow!("Failed to query stored messages: {}", response.status()));
}
let result: serde_json::Value = response.json().await?;
let mut uids = Vec::new();
if let Some(rows) = result["rows"].as_array() {
for row in rows {
if let Some(id) = row["id"].as_str() {
// Parse UID from document ID format: {mailbox}_{uid}
if let Some(uid_str) = id.strip_prefix(&format!("{}_", mailbox)) {
if let Ok(uid) = uid_str.parse::<u32>() {
uids.push(uid);
}
}
}
}
}
Ok(uids)
}
/// Delete a document (used in sync mode for deleted messages)
pub async fn delete_document(&self, db_name: &str, doc_id: &str) -> Result<()> {
// First get the document to get its revision