// Package config loads the Email Agent runtime configuration from the // environment. // // Values are read from process environment variables. In production those // variables are provided by the host (for example by // ~/.config/environment.d/10-secrets.conf) and passed into the container by // docker-compose. Secret values are never written to disk by this package and // must never be committed to the repository. package config import ( "fmt" "os" "strconv" "strings" ) // Defaults for the service. Port 8790 is recorded in AGENTS.md §9. const ( DefaultPort = 8790 DefaultEnv = "development" DefaultAddr = ":" + "8790" DefaultDB = "data/email_agent.db" // IMAP defaults. Gmail IMAP is implicit TLS on 993. IPv4 is forced in the // dialer (AGENTS.md §9): IPv6 to Gmail is broken from .13. DefaultIMAPHost = "imap.gmail.com" DefaultIMAPPort = 993 DefaultIMAPLookbackDays = 14 DefaultIMAPMaxMessages = 100 ) // Config is the fully resolved runtime configuration. type Config struct { // Env is the deployment environment: "development" or "production". Env string // Addr is the TCP address the HTTP server listens on, e.g. ":8790". Addr string // Port is the TCP port the HTTP server listens on. Port int // DBPath is the filesystem path to the SQLite database file. DBPath string // SessionSecret signs session cookies for the login screen (FR auth). // Login itself is implemented in Phase 1.2; the value is loaded here so the // service has a single configuration surface. SessionSecret string // Password is the single-user login password. When it is empty the service // falls back to SessionSecret so one configured secret is enough. Secret. Password string // LogLevel controls slog verbosity: debug, info, warn, error. LogLevel string // PublicBaseURL is the externally visible base URL of the service. PublicBaseURL string // Integration settings. These are loaded for later phases so that secret // names are defined in one place. Phase 1 does not use them. // JEVBaseURL is the base URL of the JEV/TypeSafe HTTP API. JEVBaseURL string // JEVAPIKey authenticates to JEV. Secret. JEVAPIKey string // OmniRouteBaseURL is the base URL of the OmniRoute gateway. OmniRouteBaseURL string // OmniRouteAPIKey authenticates to OmniRoute. Secret. OmniRouteAPIKey string // GmailUser is the Gmail account that receives the forwarded mail. // Secret (half of the app-password pair). GmailUser string // GmailAppPassword is the Gmail app password used for IMAP and, later, SMTP. // Secret. AGENTS.md §9: app password only, no OAuth. GmailAppPassword string // IMAPHost is the Gmail IMAP host (default imap.gmail.com). IMAPHost string // IMAPPort is the Gmail IMAP port (default 993, implicit TLS). IMAPPort int // IMAPLookbackDays bounds the first scan window. IMAPLookbackDays int // IMAPMaxMessages caps how many messages one scan fetches. IMAPMaxMessages int // HubToken authenticates the read-only Hub API. Secret. HubToken string } // Load reads configuration from the environment, applying defaults. func Load() (*Config, error) { c := &Config{ Env: getenvDefault("EMAIL_AGENT_ENV", DefaultEnv), SessionSecret: os.Getenv("EMAIL_AGENT_SESSION_SECRET"), Password: os.Getenv("EMAIL_AGENT_PASSWORD"), LogLevel: strings.ToLower(getenvDefault("EMAIL_AGENT_LOG_LEVEL", "info")), PublicBaseURL: strings.TrimRight(getenvDefault("EMAIL_AGENT_PUBLIC_BASE_URL", "http://localhost:8790"), "/"), JEVBaseURL: strings.TrimRight(os.Getenv("JEV_BASE_URL"), "/"), JEVAPIKey: os.Getenv("JEV_API_KEY"), OmniRouteBaseURL: strings.TrimRight(os.Getenv("OMNIROUTE_BASE_URL"), "/"), OmniRouteAPIKey: os.Getenv("OMNIROUTE_API_KEY"), GmailUser: os.Getenv("GMAIL_USER"), GmailAppPassword: os.Getenv("GMAIL_APP_PASSWORD"), HubToken: os.Getenv("HUB_TOKEN"), } imapPort, err := resolveIMAPPort() if err != nil { return nil, err } c.IMAPHost = getenvDefault("EMAIL_AGENT_IMAP_HOST", DefaultIMAPHost) c.IMAPPort = imapPort c.IMAPLookbackDays = getenvDefaultInt("EMAIL_AGENT_IMAP_LOOKBACK_DAYS", DefaultIMAPLookbackDays) c.IMAPMaxMessages = getenvDefaultInt("EMAIL_AGENT_IMAP_MAX_MESSAGES", DefaultIMAPMaxMessages) port, err := resolvePort() if err != nil { return nil, err } c.Port = port c.Addr = resolveAddr(port) c.DBPath = getenvDefault("EMAIL_AGENT_DB_PATH", DefaultDB) if err := c.validate(); err != nil { return nil, err } return c, nil } // resolveIMAPPort reads EMAIL_AGENT_IMAP_PORT, falling back to DefaultIMAPPort. func resolveIMAPPort() (int, error) { raw := strings.TrimSpace(os.Getenv("EMAIL_AGENT_IMAP_PORT")) if raw == "" { return DefaultIMAPPort, nil } p, err := strconv.Atoi(raw) if err != nil { return 0, fmt.Errorf("EMAIL_AGENT_IMAP_PORT %q is not a number: %w", raw, err) } return p, nil } // getenvDefaultInt reads a positive integer setting, falling back to def when // unset or malformed. func getenvDefaultInt(key string, def int) int { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return def } n, err := strconv.Atoi(raw) if err != nil || n < 1 { return def } return n } // resolvePort determines the listening port from EMAIL_AGENT_PORT or the port // component of EMAIL_AGENT_ADDR, falling back to DefaultPort. func resolvePort() (int, error) { if raw := strings.TrimSpace(os.Getenv("EMAIL_AGENT_PORT")); raw != "" { p, err := strconv.Atoi(raw) if err != nil { return 0, fmt.Errorf("EMAIL_AGENT_PORT %q is not a number: %w", raw, err) } return p, nil } if addr := strings.TrimSpace(os.Getenv("EMAIL_AGENT_ADDR")); addr != "" { if i := strings.LastIndex(addr, ":"); i >= 0 && i < len(addr)-1 { if p, err := strconv.Atoi(addr[i+1:]); err == nil { return p, nil } } } return DefaultPort, nil } // resolveAddr prefers an explicit EMAIL_AGENT_ADDR, otherwise builds one from // the resolved port. func resolveAddr(port int) string { if addr := strings.TrimSpace(os.Getenv("EMAIL_AGENT_ADDR")); addr != "" { return addr } return fmt.Sprintf(":%d", port) } func (c *Config) validate() error { if c.Port < 1 || c.Port > 65535 { return fmt.Errorf("port %d is out of range", c.Port) } if strings.TrimSpace(c.DBPath) == "" { return fmt.Errorf("EMAIL_AGENT_DB_PATH must not be empty") } switch c.Env { case "development", "production", "test": default: return fmt.Errorf("EMAIL_AGENT_ENV %q must be development, production, or test", c.Env) } switch c.LogLevel { case "debug", "info", "warn", "error": default: return fmt.Errorf("EMAIL_AGENT_LOG_LEVEL %q must be debug, info, warn, or error", c.LogLevel) } if strings.TrimSpace(c.IMAPHost) == "" { return fmt.Errorf("EMAIL_AGENT_IMAP_HOST must not be empty") } if c.IMAPPort < 1 || c.IMAPPort > 65535 { return fmt.Errorf("EMAIL_AGENT_IMAP_PORT %d is out of range", c.IMAPPort) } return nil } // GmailConfigured reports whether an IMAP login can be attempted. The service // still starts without credentials; only mail sync is unavailable. func (c *Config) GmailConfigured() bool { return strings.TrimSpace(c.GmailUser) != "" && strings.TrimSpace(c.GmailAppPassword) != "" } // IsProduction reports whether the service runs in the production environment. func (c *Config) IsProduction() bool { return c.Env == "production" } func getenvDefault(key, def string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return def }