// 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 // SMTP defaults (AGENTS.md §9): Gmail submission on 587 with STARTTLS. The // dialer forces IPv4, matching IMAP. DefaultSMTPHost = "smtp.gmail.com" DefaultSMTPPort = 587 // JEV (TypeSafe System One) defaults. The base URL is fixed by the TypeSafe // docs (https://docs.typesafe.ai/api.md); JEV_BASE_URL overrides it. DefaultJEVBaseURL = "https://api.typesafe.ai" DefaultJEVModel = "jev-latest" // Classifier defaults (FR-3). Concurrency bounds in-flight JEV requests; // max messages bounds one run so a scheduled scan stays cheap. DefaultClassifyConcurrency = 4 DefaultClassifyMaxMessages = 200 // OmniRoute defaults (AGENTS.md §8/§10). Reply drafts are the first caller. // The gateway runs on .13, the host that runs this service; the other house // apps use the same API endpoint. OMNIROUTE_BASE_URL overrides it. DefaultOmniRouteBaseURL = "http://192.168.20.13:20129/v1" DefaultOmniRouteModel = "auto/best-chat" ) // 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 // JEVModel is the System One model alias (default jev-latest). JEVModel string // ClassifyConcurrency bounds in-flight JEV requests per run. ClassifyConcurrency int // ClassifyMaxMessages caps how many messages one classify run processes. ClassifyMaxMessages int // OmniRouteBaseURL is the base URL of the OmniRoute gateway. All non-JEV // models go through it (AGENTS.md §10). OmniRouteBaseURL string // OmniRouteAPIKey authenticates to OmniRoute. Secret. OmniRouteAPIKey string // OmniRouteModel is the model alias used for reply drafts. OmniRouteModel 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 // SMTPHost is the Gmail submission host (default smtp.gmail.com). SMTPHost string // SMTPPort is the Gmail submission port (default 587, STARTTLS). SMTPPort 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(getenvDefault("JEV_BASE_URL", DefaultJEVBaseURL), "/"), JEVAPIKey: os.Getenv("JEV_API_KEY"), JEVModel: getenvDefault("JEV_MODEL", DefaultJEVModel), OmniRouteBaseURL: strings.TrimRight(getenvDefault("OMNIROUTE_BASE_URL", DefaultOmniRouteBaseURL), "/"), OmniRouteAPIKey: os.Getenv("OMNIROUTE_API_KEY"), OmniRouteModel: getenvDefault("OMNIROUTE_MODEL", DefaultOmniRouteModel), 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 smtpPort, err := resolvePortSetting("EMAIL_AGENT_SMTP_PORT", DefaultSMTPPort) if err != nil { return nil, err } c.SMTPHost = getenvDefault("EMAIL_AGENT_SMTP_HOST", DefaultSMTPHost) c.SMTPPort = smtpPort c.IMAPLookbackDays = getenvDefaultInt("EMAIL_AGENT_IMAP_LOOKBACK_DAYS", DefaultIMAPLookbackDays) c.IMAPMaxMessages = getenvDefaultInt("EMAIL_AGENT_IMAP_MAX_MESSAGES", DefaultIMAPMaxMessages) c.ClassifyConcurrency = getenvDefaultInt("EMAIL_AGENT_CLASSIFY_CONCURRENCY", DefaultClassifyConcurrency) c.ClassifyMaxMessages = getenvDefaultInt("EMAIL_AGENT_CLASSIFY_MAX_MESSAGES", DefaultClassifyMaxMessages) 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) { return resolvePortSetting("EMAIL_AGENT_IMAP_PORT", DefaultIMAPPort) } // resolvePortSetting reads a port-valued environment variable, falling back to // def when unset. func resolvePortSetting(key string, def int) (int, error) { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return def, nil } p, err := strconv.Atoi(raw) if err != nil { return 0, fmt.Errorf("%s %q is not a number: %w", key, 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) } if strings.TrimSpace(c.SMTPHost) == "" { return fmt.Errorf("EMAIL_AGENT_SMTP_HOST must not be empty") } if c.SMTPPort < 1 || c.SMTPPort > 65535 { return fmt.Errorf("EMAIL_AGENT_SMTP_PORT %d is out of range", c.SMTPPort) } 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) != "" } // JEVConfigured reports whether JEV classification can be attempted. The // service still starts without a key; only classification is unavailable. func (c *Config) JEVConfigured() bool { return strings.TrimSpace(c.JEVAPIKey) != "" } // 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 }