2025-08-02 19:52:14 +02:00
|
|
|
//! 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>,
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 14:29:49 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-02 19:52:14 +02:00
|
|
|
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>,
|
2025-08-03 18:26:01 +02:00
|
|
|
pub dry_run: bool,
|
2025-08-02 19:52:14 +02:00
|
|
|
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,
|
2025-08-03 18:26:01 +02:00
|
|
|
dry_run: false,
|
2025-08-02 19:52:14 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|