// 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" ) // 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 // GmailCredentialsFile is the OAuth client credentials JSON path. GmailCredentialsFile string // GmailTokenFile is the path where the OAuth token is cached. GmailTokenFile string // 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"), GmailCredentialsFile: os.Getenv("EMAIL_AGENT_GMAIL_CREDENTIALS_FILE"), GmailTokenFile: os.Getenv("EMAIL_AGENT_GMAIL_TOKEN_FILE"), HubToken: os.Getenv("HUB_TOKEN"), } 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 } // 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) } return nil } // 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 }