- 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>
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
type Config struct {
|
|
CouchDb CouchDbConfig `json:"couchDb"`
|
|
MailSources []MailSource `json:"mailSources"`
|
|
}
|
|
|
|
type CouchDbConfig struct {
|
|
URL string `json:"url"`
|
|
User string `json:"user"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type MailSource struct {
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Protocol string `json:"protocol"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user"`
|
|
Password string `json:"password"`
|
|
Mode string `json:"mode"` // "sync" or "archive"
|
|
FolderFilter FolderFilter `json:"folderFilter"`
|
|
MessageFilter MessageFilter `json:"messageFilter"`
|
|
}
|
|
|
|
type FolderFilter struct {
|
|
Include []string `json:"include"`
|
|
Exclude []string `json:"exclude"`
|
|
}
|
|
|
|
type MessageFilter struct {
|
|
Since string `json:"since,omitempty"`
|
|
SubjectKeywords []string `json:"subjectKeywords,omitempty"` // Filter by keywords in subject
|
|
SenderKeywords []string `json:"senderKeywords,omitempty"` // Filter by keywords in sender addresses
|
|
RecipientKeywords []string `json:"recipientKeywords,omitempty"` // Filter by keywords in recipient addresses
|
|
}
|
|
|
|
// HasKeywordFilters checks if this filter has any keyword-based filters that can use IMAP SEARCH
|
|
func (mf *MessageFilter) HasKeywordFilters() bool {
|
|
return len(mf.SubjectKeywords) > 0 || len(mf.SenderKeywords) > 0
|
|
// Note: RecipientKeywords not included as IMAP SEARCH doesn't have a reliable TO field search
|
|
}
|
|
|
|
func LoadConfig(path string) (*Config, error) {
|
|
configFile, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer configFile.Close()
|
|
|
|
var config Config
|
|
jsonParser := json.NewDecoder(configFile)
|
|
if err = jsonParser.Decode(&config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate and set defaults for mail sources
|
|
for i := range config.MailSources {
|
|
source := &config.MailSources[i]
|
|
if source.Mode == "" {
|
|
source.Mode = "archive" // Default to archive mode
|
|
}
|
|
if source.Mode != "sync" && source.Mode != "archive" {
|
|
return nil, fmt.Errorf("invalid mode '%s' for mail source '%s': must be 'sync' or 'archive'", source.Mode, source.Name)
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// IsArchiveMode returns true if the mail source is in archive mode
|
|
func (ms *MailSource) IsArchiveMode() bool {
|
|
return ms.Mode == "archive" || ms.Mode == "" // Default to archive
|
|
}
|
|
|
|
// CommandLineArgs holds parsed command line arguments
|
|
type CommandLineArgs struct {
|
|
ConfigPath string
|
|
MaxMessages int
|
|
DryRun bool
|
|
}
|
|
|
|
// ParseCommandLine parses command line arguments using GNU-style options
|
|
func ParseCommandLine() *CommandLineArgs {
|
|
var args CommandLineArgs
|
|
|
|
// Define long options with -- and short options with -
|
|
pflag.StringVarP(&args.ConfigPath, "config", "c", "", "Path to configuration file")
|
|
pflag.IntVarP(&args.MaxMessages, "max-messages", "m", 0, "Maximum number of messages to process per mailbox per run (0 = no limit)")
|
|
pflag.BoolVarP(&args.DryRun, "dry-run", "n", false, "Show what would be done without making any changes")
|
|
|
|
// Add utility options
|
|
pflag.BoolP("help", "h", false, "Show help message")
|
|
pflag.Bool("generate-bash-completion", false, "Generate bash completion script and exit")
|
|
|
|
pflag.Parse()
|
|
|
|
// Handle help flag
|
|
if help, _ := pflag.CommandLine.GetBool("help"); help {
|
|
fmt.Fprintf(os.Stderr, "mail2couch - Email backup utility for CouchDB\n\n")
|
|
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)
|
|
}
|
|
|
|
// Handle bash completion generation
|
|
if generateCompletion, _ := pflag.CommandLine.GetBool("generate-bash-completion"); generateCompletion {
|
|
GenerateBashCompletion()
|
|
os.Exit(0)
|
|
}
|
|
|
|
return &args
|
|
}
|
|
|
|
// GenerateBashCompletion generates a bash completion script for mail2couch
|
|
func GenerateBashCompletion() {
|
|
appName := filepath.Base(os.Args[0])
|
|
script := fmt.Sprintf(`#!/bin/bash
|
|
# Bash completion script for %s
|
|
# Generated automatically by %s --generate-bash-completion
|
|
|
|
_%s_completions() {
|
|
local cur prev words cword
|
|
_init_completion || return
|
|
|
|
case $prev in
|
|
-c|--config)
|
|
# Complete config files (*.json)
|
|
_filedir "json"
|
|
return
|
|
;;
|
|
-m|--max-messages)
|
|
# Complete with numbers, suggest common values
|
|
COMPREPLY=($(compgen -W "10 50 100 500 1000" -- "$cur"))
|
|
return
|
|
;;
|
|
esac
|
|
|
|
if [[ $cur == -* ]]; then
|
|
# Complete with available options
|
|
local opts="-c --config -m --max-messages -n --dry-run -h --help --generate-bash-completion"
|
|
COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
|
return
|
|
fi
|
|
|
|
# No default completion for other cases
|
|
}
|
|
|
|
# Register the completion function
|
|
complete -F _%s_completions %s
|
|
|
|
# Enable completion for common variations of the command name
|
|
if [[ "$(%s --help 2>/dev/null)" =~ "mail2couch" ]]; then
|
|
complete -F _%s_completions mail2couch
|
|
fi
|
|
`, appName, appName, appName, appName, appName, appName, appName)
|
|
|
|
fmt.Print(script)
|
|
}
|
|
|
|
// FindConfigFile searches for config.json in the following order:
|
|
// 1. Path specified by --config/-c flag
|
|
// 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)
|
|
func FindConfigFile(args *CommandLineArgs) (string, error) {
|
|
if args.ConfigPath != "" {
|
|
if _, err := os.Stat(args.ConfigPath); err == nil {
|
|
return args.ConfigPath, nil
|
|
}
|
|
return "", fmt.Errorf("specified config file not found: %s", args.ConfigPath)
|
|
}
|
|
|
|
// List of possible config file locations in order of preference
|
|
candidates := []string{
|
|
"config.json", // Current directory
|
|
"config/config.json", // Config subdirectory
|
|
}
|
|
|
|
// Add user directory paths
|
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
|
candidates = append(candidates,
|
|
filepath.Join(homeDir, ".config", "mail2couch", "config.json"),
|
|
filepath.Join(homeDir, ".mail2couch.json"),
|
|
)
|
|
}
|
|
|
|
// Try each candidate location
|
|
for _, candidate := range candidates {
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fmt.Printf("Using configuration file: %s\n", configPath)
|
|
if args.MaxMessages > 0 {
|
|
fmt.Printf("Maximum messages per mailbox: %d\n", args.MaxMessages)
|
|
} else {
|
|
fmt.Printf("Maximum messages per mailbox: unlimited\n")
|
|
}
|
|
if args.DryRun {
|
|
fmt.Printf("DRY-RUN MODE: No changes will be made to CouchDB\n")
|
|
}
|
|
|
|
config, err := LoadConfig(configPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Show which environment variable overrides are active
|
|
showEnvironmentOverrides(config)
|
|
|
|
return config, nil
|
|
}
|