feat: implement comprehensive environment variable credential support

- Add environment variable overrides for sensitive credentials in both Go and Rust implementations
- Support MAIL2COUCH_COUCHDB_USER and MAIL2COUCH_COUCHDB_PASSWORD for CouchDB credentials
- Support MAIL2COUCH_IMAP_<NAME>_USER and MAIL2COUCH_IMAP_<NAME>_PASSWORD for IMAP credentials
- Implement automatic name normalization for mail source names to environment variable format
- Add runtime display of active environment variable overrides
- Enhance --help output in both implementations with comprehensive environment variable documentation
- Add detailed environment variable section to README with usage examples and security benefits
- Create comprehensive ENVIRONMENT_VARIABLES.md reference guide with SystemD, Docker, and CI/CD examples
- Update all documentation indices and cross-references
- Include security best practices and troubleshooting guidance
- Maintain full backward compatibility with existing configuration files

This enhancement addresses the high-priority security requirement to eliminate plaintext
passwords from configuration files while providing production-ready credential management
for both development and deployment scenarios.

🤖 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-07 15:09:34 +02:00
commit 8764b44a05
9 changed files with 532 additions and 13 deletions

View file

@ -15,6 +15,23 @@ pub fn parse_command_line() -> CommandLineArgs {
.version(env!("CARGO_PKG_VERSION"))
.about("Email backup utility for CouchDB")
.long_about("A powerful email backup utility that synchronizes mail from IMAP accounts to CouchDB databases with intelligent incremental sync, comprehensive filtering, and native attachment support.")
.after_help(r#"Environment Variables:
Override sensitive credentials to avoid storing them in config files:
MAIL2COUCH_COUCHDB_USER Override CouchDB username
MAIL2COUCH_COUCHDB_PASSWORD Override CouchDB password
MAIL2COUCH_IMAP_<NAME>_USER Override IMAP username for source <NAME>
MAIL2COUCH_IMAP_<NAME>_PASSWORD Override IMAP password for source <NAME>
Where <NAME> is the mail source name from config.json converted to
uppercase with non-alphanumeric characters replaced by underscores.
Example: "Personal Gmail" -> "PERSONAL_GMAIL"
Examples:
mail2couch --config /path/to/config.json
mail2couch --max-messages 100 --dry-run
MAIL2COUCH_COUCHDB_PASSWORD=secret mail2couch
MAIL2COUCH_IMAP_WORK_EMAIL_PASSWORD=app-pass mail2couch"#)
.arg(Arg::new("config")
.short('c')
.long("config")

View file

@ -4,6 +4,7 @@
//! file discovery, matching the behavior of the Go implementation.
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
use thiserror::Error;
@ -131,6 +132,9 @@ impl Config {
}
}
// Apply environment variable overrides for sensitive credentials
config.apply_environment_overrides();
Ok(config)
}
@ -186,6 +190,109 @@ impl Config {
let config = Self::load_from_path(config_path.to_str().unwrap())?;
Ok((config, config_path))
}
/// Apply environment variable overrides for sensitive credentials
/// This allows users to keep credentials out of config files while maintaining security
///
/// Environment variable patterns:
/// - MAIL2COUCH_COUCHDB_USER: Override CouchDB username
/// - MAIL2COUCH_COUCHDB_PASSWORD: Override CouchDB password
/// - MAIL2COUCH_IMAP_<NAME>_USER: Override IMAP username for source named <NAME>
/// - MAIL2COUCH_IMAP_<NAME>_PASSWORD: Override IMAP password for source named <NAME>
///
/// The <NAME> part is the mail source name converted to uppercase with non-alphanumeric characters replaced with underscores
fn apply_environment_overrides(&mut self) {
// CouchDB credential overrides
if let Ok(user) = env::var("MAIL2COUCH_COUCHDB_USER") {
if !user.is_empty() {
self.couch_db.user = user;
}
}
if let Ok(password) = env::var("MAIL2COUCH_COUCHDB_PASSWORD") {
if !password.is_empty() {
self.couch_db.password = password;
}
}
// IMAP credential overrides for each mail source
for source in &mut self.mail_sources {
// Convert source name to environment variable suffix
let env_suffix = normalize_name_for_env_var(&source.name);
let user_env_var = format!("MAIL2COUCH_IMAP_{}_USER", env_suffix);
if let Ok(user) = env::var(&user_env_var) {
if !user.is_empty() {
source.user = user;
}
}
let password_env_var = format!("MAIL2COUCH_IMAP_{}_PASSWORD", env_suffix);
if let Ok(password) = env::var(&password_env_var) {
if !password.is_empty() {
source.password = password;
}
}
}
}
/// Show which environment variable overrides are active
pub fn show_environment_overrides(&self) {
let mut overrides = Vec::new();
// Check CouchDB overrides
if env::var("MAIL2COUCH_COUCHDB_USER").is_ok() {
overrides.push("MAIL2COUCH_COUCHDB_USER");
}
if env::var("MAIL2COUCH_COUCHDB_PASSWORD").is_ok() {
overrides.push("MAIL2COUCH_COUCHDB_PASSWORD");
}
// Check IMAP overrides for each source
for source in &self.mail_sources {
let env_suffix = normalize_name_for_env_var(&source.name);
let user_env_var = format!("MAIL2COUCH_IMAP_{}_USER", env_suffix);
if env::var(&user_env_var).is_ok() {
overrides.push(Box::leak(user_env_var.into_boxed_str()));
}
let password_env_var = format!("MAIL2COUCH_IMAP_{}_PASSWORD", env_suffix);
if env::var(&password_env_var).is_ok() {
overrides.push(Box::leak(password_env_var.into_boxed_str()));
}
}
if !overrides.is_empty() {
println!("Active environment variable overrides: {}", overrides.join(", "));
}
}
}
/// Normalize a source name to a valid environment variable suffix
/// Example: "Personal Gmail" -> "PERSONAL_GMAIL"
fn normalize_name_for_env_var(name: &str) -> String {
let upper_name = name.to_uppercase();
// Replace non-alphanumeric characters with underscores
let normalized: String = upper_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c
} else {
'_'
}
})
.collect();
// Clean up multiple consecutive underscores and trim
let cleaned = normalized
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
cleaned
}
#[cfg(test)]

View file

@ -62,6 +62,9 @@ fn print_config_summary(config: &mail2couch::config::Config) {
info!(" CouchDB: {}", config.couch_db.url);
info!(" Mail sources: {}", config.mail_sources.len());
// Show which environment variable overrides are active
config.show_environment_overrides();
for (i, source) in config.mail_sources.iter().enumerate() {
let status = if source.enabled {
"enabled"