feat: implement complete Rust version of mail2couch
- Add comprehensive Rust implementation matching Go functionality - Configuration loading with automatic file discovery - GNU-style command line parsing with clap (--config/-c, --max-messages/-m) - CouchDB client integration with document storage and sync metadata - IMAP client functionality with message fetching and parsing - Folder filtering with wildcard pattern support (*, ?, [abc]) - Message filtering by subject, sender, and recipient keywords - Incremental sync functionality with metadata tracking - Bash completion generation matching Go implementation - Cross-compatible document schemas and database structures - Successfully tested with existing test environment Note: TLS support and advanced email parsing features pending 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
88a5bfb42b
commit
4835df070e
9 changed files with 1901 additions and 8 deletions
287
rust/src/config.rs
Normal file
287
rust/src/config.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! Configuration loading and management for mail2couch
|
||||
//!
|
||||
//! This module handles loading configuration from JSON files with automatic
|
||||
//! file discovery, matching the behavior of the Go implementation.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConfigError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("JSON parsing error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Configuration file not found. Searched: {paths:?}")]
|
||||
NotFound { paths: Vec<PathBuf> },
|
||||
#[error("Invalid mode '{mode}' for mail source '{name}': must be 'sync' or 'archive'")]
|
||||
InvalidMode { mode: String, name: String },
|
||||
}
|
||||
|
||||
/// Main configuration structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(rename = "couchDb")]
|
||||
pub couch_db: CouchDbConfig,
|
||||
#[serde(rename = "mailSources")]
|
||||
pub mail_sources: Vec<MailSource>,
|
||||
}
|
||||
|
||||
/// CouchDB connection configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CouchDbConfig {
|
||||
pub url: String,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Mail source configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MailSource {
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub protocol: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(rename = "folderFilter", default)]
|
||||
pub folder_filter: FolderFilter,
|
||||
#[serde(rename = "messageFilter", default)]
|
||||
pub message_filter: MessageFilter,
|
||||
}
|
||||
|
||||
/// Folder filtering configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct FolderFilter {
|
||||
#[serde(default)]
|
||||
pub include: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
/// Message filtering configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MessageFilter {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub since: Option<String>,
|
||||
#[serde(rename = "subjectKeywords", default)]
|
||||
pub subject_keywords: Vec<String>,
|
||||
#[serde(rename = "senderKeywords", default)]
|
||||
pub sender_keywords: Vec<String>,
|
||||
#[serde(rename = "recipientKeywords", default)]
|
||||
pub recipient_keywords: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_mode() -> String {
|
||||
"archive".to_string()
|
||||
}
|
||||
|
||||
impl MailSource {
|
||||
/// Returns true if the mail source is in sync mode
|
||||
pub fn is_sync_mode(&self) -> bool {
|
||||
self.mode == "sync"
|
||||
}
|
||||
|
||||
/// Returns true if the mail source is in archive mode
|
||||
pub fn is_archive_mode(&self) -> bool {
|
||||
self.mode == "archive" || self.mode.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Command line arguments
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandLineArgs {
|
||||
pub config_path: Option<String>,
|
||||
pub max_messages: Option<u32>,
|
||||
pub generate_bash_completion: bool,
|
||||
pub help: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from a file path
|
||||
pub fn load_from_path(path: &str) -> Result<Self, ConfigError> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut config: Config = serde_json::from_str(&content)?;
|
||||
|
||||
// Validate and set defaults for mail sources
|
||||
for source in &mut config.mail_sources {
|
||||
if source.mode.is_empty() {
|
||||
source.mode = "archive".to_string();
|
||||
}
|
||||
if source.mode != "sync" && source.mode != "archive" {
|
||||
return Err(ConfigError::InvalidMode {
|
||||
mode: source.mode.clone(),
|
||||
name: source.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Find configuration file in standard locations
|
||||
/// Searches in the same order as the Go implementation:
|
||||
/// 1. Path specified by command line argument
|
||||
/// 2. ./config.json (current directory)
|
||||
/// 3. ./config/config.json (config subdirectory)
|
||||
/// 4. ~/.config/mail2couch/config.json (user config directory)
|
||||
/// 5. ~/.mail2couch.json (user home directory)
|
||||
pub fn find_config_file(args: &CommandLineArgs) -> Result<PathBuf, ConfigError> {
|
||||
// If a specific path was provided, check it first
|
||||
if let Some(path) = &args.config_path {
|
||||
let path_buf = PathBuf::from(path);
|
||||
if path_buf.exists() {
|
||||
return Ok(path_buf);
|
||||
}
|
||||
return Err(ConfigError::NotFound {
|
||||
paths: vec![path_buf],
|
||||
});
|
||||
}
|
||||
|
||||
// List of candidate locations
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("config.json"),
|
||||
PathBuf::from("config/config.json"),
|
||||
];
|
||||
|
||||
// Add user directory paths
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
candidates.push(home_dir.join(".config").join("mail2couch").join("config.json"));
|
||||
candidates.push(home_dir.join(".mail2couch.json"));
|
||||
}
|
||||
|
||||
// Try each candidate
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err(ConfigError::NotFound {
|
||||
paths: candidates,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load configuration with automatic file discovery
|
||||
pub fn load_with_discovery(args: &CommandLineArgs) -> Result<(Self, PathBuf), ConfigError> {
|
||||
let config_path = Self::find_config_file(args)?;
|
||||
let config = Self::load_from_path(config_path.to_str().unwrap())?;
|
||||
Ok((config, config_path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_config_parsing() {
|
||||
let config_json = r#"
|
||||
{
|
||||
"couchDb": {
|
||||
"url": "http://localhost:5984",
|
||||
"user": "admin",
|
||||
"password": "password"
|
||||
},
|
||||
"mailSources": [
|
||||
{
|
||||
"name": "Test Account",
|
||||
"enabled": true,
|
||||
"protocol": "imap",
|
||||
"host": "imap.example.com",
|
||||
"port": 993,
|
||||
"user": "test@example.com",
|
||||
"password": "testpass",
|
||||
"mode": "archive",
|
||||
"folderFilter": {
|
||||
"include": ["INBOX", "Sent"],
|
||||
"exclude": ["Trash"]
|
||||
},
|
||||
"messageFilter": {
|
||||
"since": "2024-01-01",
|
||||
"subjectKeywords": ["urgent"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
let config: Config = serde_json::from_str(config_json).unwrap();
|
||||
|
||||
assert_eq!(config.couch_db.url, "http://localhost:5984");
|
||||
assert_eq!(config.mail_sources.len(), 1);
|
||||
|
||||
let source = &config.mail_sources[0];
|
||||
assert_eq!(source.name, "Test Account");
|
||||
assert_eq!(source.mode, "archive");
|
||||
assert!(source.is_archive_mode());
|
||||
assert!(!source.is_sync_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_mode() {
|
||||
let config_json = r#"
|
||||
{
|
||||
"couchDb": {
|
||||
"url": "http://localhost:5984",
|
||||
"user": "admin",
|
||||
"password": "password"
|
||||
},
|
||||
"mailSources": [
|
||||
{
|
||||
"name": "Test Account",
|
||||
"enabled": true,
|
||||
"protocol": "imap",
|
||||
"host": "imap.example.com",
|
||||
"port": 993,
|
||||
"user": "test@example.com",
|
||||
"password": "testpass"
|
||||
}
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
let config: Config = serde_json::from_str(config_json).unwrap();
|
||||
let source = &config.mail_sources[0];
|
||||
assert_eq!(source.mode, "archive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_file_discovery() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let config_path = temp_dir.path().join("config.json");
|
||||
|
||||
let config_content = r#"
|
||||
{
|
||||
"couchDb": {"url": "http://localhost:5984", "user": "admin", "password": "password"},
|
||||
"mailSources": []
|
||||
}
|
||||
"#;
|
||||
|
||||
fs::write(&config_path, config_content).unwrap();
|
||||
|
||||
// Change to temp directory for relative path test
|
||||
let original_dir = std::env::current_dir().unwrap();
|
||||
std::env::set_current_dir(&temp_dir).unwrap();
|
||||
|
||||
let args = CommandLineArgs {
|
||||
config_path: None,
|
||||
max_messages: None,
|
||||
generate_bash_completion: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
let found_path = Config::find_config_file(&args).unwrap();
|
||||
assert_eq!(found_path, PathBuf::from("config.json"));
|
||||
|
||||
// Restore original directory
|
||||
std::env::set_current_dir(original_dir).unwrap();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue