Implements FR-3, FR-4, FR-5 and FR-14. - internal/classify: TypeSafe System One (JEV) HTTP client with the confirmed request/response shape, retry with backoff on 429/529/5xx, a plain-text rule engine (first match wins, overrides only the named fields), and bounded orchestration with a per-run cap. - internal/store: migration v3 adds an editable `categories` table (name = JEV Choice option and classifications key; rename cascades, remove reassigns) and seeds a starter rules version once. Classification persistence, coverage, and pending/all message queries. - internal/server: POST /actions/classify and /actions/reclassify, plus the FR-14 category editor (GET /categories and add/rename/remove). The triage list now groups by the live category list and shows action + confidence. - CLI: -classify and -classify-all for n8n. - Docs: docs/phase3-classify.md with the confirmed JEV shape, a live read-only classification, and the DoD evidence. DoD: all 100 stored messages have a non-null category, importance, and suggested action (0 nulls); JEV picked a newly added/renamed category for 6 real messages over HTTP. One live JEV call recorded in the phase doc. Co-Authored-By: Paperclip <noreply@paperclip.ing>
254 lines
8.6 KiB
Go
254 lines
8.6 KiB
Go
// 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
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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.
|
|
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(getenvDefault("JEV_BASE_URL", DefaultJEVBaseURL), "/"),
|
|
JEVAPIKey: os.Getenv("JEV_API_KEY"),
|
|
JEVModel: getenvDefault("JEV_MODEL", DefaultJEVModel),
|
|
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)
|
|
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) {
|
|
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) != ""
|
|
}
|
|
|
|
// 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
|
|
}
|