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

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/pflag"
)
@ -75,9 +76,71 @@ func LoadConfig(path string) (*Config, error) {
}
}
// Apply environment variable overrides for sensitive credentials
applyEnvironmentOverrides(&config)
return &config, nil
}
// applyEnvironmentOverrides applies 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
func applyEnvironmentOverrides(config *Config) {
// CouchDB credential overrides
if user := os.Getenv("MAIL2COUCH_COUCHDB_USER"); user != "" {
config.CouchDb.User = user
}
if password := os.Getenv("MAIL2COUCH_COUCHDB_PASSWORD"); password != "" {
config.CouchDb.Password = password
}
// IMAP credential overrides for each mail source
for i := range config.MailSources {
source := &config.MailSources[i]
// Convert source name to environment variable suffix
// Replace non-alphanumeric characters with underscores and uppercase
envSuffix := normalizeNameForEnvVar(source.Name)
userEnvVar := fmt.Sprintf("MAIL2COUCH_IMAP_%s_USER", envSuffix)
if user := os.Getenv(userEnvVar); user != "" {
source.User = user
}
passwordEnvVar := fmt.Sprintf("MAIL2COUCH_IMAP_%s_PASSWORD", envSuffix)
if password := os.Getenv(passwordEnvVar); password != "" {
source.Password = password
}
}
}
// normalizeNameForEnvVar converts a source name to a valid environment variable suffix
// Example: "Personal Gmail" -> "PERSONAL_GMAIL"
func normalizeNameForEnvVar(name string) string {
result := strings.ToUpper(name)
// Replace non-alphanumeric characters with underscores
var normalized strings.Builder
for _, char := range result {
if (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
normalized.WriteRune(char)
} else {
normalized.WriteRune('_')
}
}
// Clean up multiple consecutive underscores and trim
cleaned := strings.Trim(strings.ReplaceAll(normalized.String(), "__", "_"), "_")
return cleaned
}
// IsSyncMode returns true if the mail source is in sync mode
func (ms *MailSource) IsSyncMode() bool {
return ms.Mode == "sync"
@ -116,6 +179,21 @@ func ParseCommandLine() *CommandLineArgs {
fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Options:\n")
pflag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nEnvironment Variables:\n")
fmt.Fprintf(os.Stderr, " Override sensitive credentials to avoid storing them in config files:\n\n")
fmt.Fprintf(os.Stderr, " MAIL2COUCH_COUCHDB_USER Override CouchDB username\n")
fmt.Fprintf(os.Stderr, " MAIL2COUCH_COUCHDB_PASSWORD Override CouchDB password\n")
fmt.Fprintf(os.Stderr, " MAIL2COUCH_IMAP_<NAME>_USER Override IMAP username for source <NAME>\n")
fmt.Fprintf(os.Stderr, " MAIL2COUCH_IMAP_<NAME>_PASSWORD Override IMAP password for source <NAME>\n\n")
fmt.Fprintf(os.Stderr, " Where <NAME> is the mail source name from config.json converted to\n")
fmt.Fprintf(os.Stderr, " uppercase with non-alphanumeric characters replaced by underscores.\n")
fmt.Fprintf(os.Stderr, " Example: \"Personal Gmail\" -> \"PERSONAL_GMAIL\"\n\n")
fmt.Fprintf(os.Stderr, "Examples:\n")
fmt.Fprintf(os.Stderr, " %s --config /path/to/config.json\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --max-messages 100 --dry-run\n", os.Args[0])
fmt.Fprintf(os.Stderr, " MAIL2COUCH_COUCHDB_PASSWORD=secret %s\n", os.Args[0])
fmt.Fprintf(os.Stderr, " MAIL2COUCH_IMAP_WORK_EMAIL_PASSWORD=app-pass %s\n", os.Args[0])
os.Stderr.Sync()
os.Exit(0)
}
@ -212,6 +290,38 @@ func FindConfigFile(args *CommandLineArgs) (string, error) {
return "", fmt.Errorf("no configuration file found. Searched locations: %v", candidates)
}
// showEnvironmentOverrides displays which environment variable overrides are active
func showEnvironmentOverrides(config *Config) {
var overrides []string
// Check CouchDB overrides
if os.Getenv("MAIL2COUCH_COUCHDB_USER") != "" {
overrides = append(overrides, "MAIL2COUCH_COUCHDB_USER")
}
if os.Getenv("MAIL2COUCH_COUCHDB_PASSWORD") != "" {
overrides = append(overrides, "MAIL2COUCH_COUCHDB_PASSWORD")
}
// Check IMAP overrides for each source
for _, source := range config.MailSources {
envSuffix := normalizeNameForEnvVar(source.Name)
userEnvVar := fmt.Sprintf("MAIL2COUCH_IMAP_%s_USER", envSuffix)
if os.Getenv(userEnvVar) != "" {
overrides = append(overrides, userEnvVar)
}
passwordEnvVar := fmt.Sprintf("MAIL2COUCH_IMAP_%s_PASSWORD", envSuffix)
if os.Getenv(passwordEnvVar) != "" {
overrides = append(overrides, passwordEnvVar)
}
}
if len(overrides) > 0 {
fmt.Printf("Active environment variable overrides: %s\n", strings.Join(overrides, ", "))
}
}
// LoadConfigWithDiscovery loads configuration using automatic file discovery
func LoadConfigWithDiscovery(args *CommandLineArgs) (*Config, error) {
configPath, err := FindConfigFile(args)
@ -228,5 +338,14 @@ func LoadConfigWithDiscovery(args *CommandLineArgs) (*Config, error) {
if args.DryRun {
fmt.Printf("DRY-RUN MODE: No changes will be made to CouchDB\n")
}
return LoadConfig(configPath)
config, err := LoadConfig(configPath)
if err != nil {
return nil, err
}
// Show which environment variable overrides are active
showEnvironmentOverrides(config)
return config, nil
}