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__USER: Override IMAP username for source named // - MAIL2COUCH_IMAP__PASSWORD: Override IMAP password for source named // // The 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__USER Override IMAP username for source \n") fmt.Fprintf(os.Stderr, " MAIL2COUCH_IMAP__PASSWORD Override IMAP password for source \n\n") fmt.Fprintf(os.Stderr, " Where 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 }