Files
email_agent/internal/config/config.go
Sam Rolfe 17d605dfd5 Phase 7 follow-up: 1-month body retention and actions_log cap (AUD-75)
Implements the AGENTS.md §7 data rule: keep message bodies for one month, and
size-cap the append-only action log.

- internal/store/retention.go: Store.Retain clears messages.body_text older
  than the window (metadata stays) and deletes actions_log rows beyond the
  newest N, in one transaction.
- internal/config: EMAIL_AGENT_RETENTION_DAYS (30) and
  EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS (50000).
- cmd/email-agent: bounded -retain flag for the n8n schedule.
- Tests for body clearing, metadata retention, log cap, and disabled policy.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 10:45:16 +11:00

377 lines
14 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"
"time"
)
// 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"
// Hub defaults (Phase 7, FR-12). The central Hub runs on .13:8788. All
// non-JEV model calls and project notifications leave through it.
DefaultHubBaseURL = "http://192.168.20.13:8788"
DefaultHubNotifyTimout = 5 * time.Second
// Apprise defaults (FR-10). The house Apprise API runs on .35:8210 and
// fans out to email/NTFY/Telegram. SUMMARY_TO is the summary recipient.
DefaultAppriseURL = "http://192.168.20.35:8210"
DefaultSummaryTo = "samuelrolfe@gmail.com"
// Retention defaults (AGENTS.md §7): keep message bodies for one month, and
// cap the append-only action log. The retention job is the CLI `-retain`.
DefaultRetentionDays = 30
DefaultActionsLogMaxRows = 50000
)
// 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 (FR-12). Secret. The Hub
// sends it as a bearer token; this service never trusts an unsigned request.
HubToken string
// HubBaseURL is the base URL of the central Hub, used by the outbound
// POST /notify path (ask-contract.md). Not secret.
HubBaseURL string
// HubNotifyTimeout bounds one outbound notify call.
HubNotifyTimeout time.Duration
// Apprise (FR-10): the daily summary is delivered by email through Apprise.
// AppriseURL is the Apprise API base URL (default the house instance on
// .35). AppriseKey names a stored Apprise configuration. AppriseURLs is the
// stateless fallback used when no key is set. AppriseToken is an optional
// bearer token for a protected Apprise API. All values come from the host
// environment; none are committed.
AppriseURL string
AppriseKey string
AppriseURLs []string
AppriseToken string
// SummaryTo is the recipient address for the daily summary email.
SummaryTo string
// RetentionDays is the message-body window in days (AGENTS.md §7: one
// month). Bodies of messages received more than this many days ago are
// cleared by the `-retain` job; metadata is kept.
RetentionDays int
// ActionsLogMaxRows caps the append-only actions_log. The oldest rows beyond
// this count are deleted by the `-retain` job.
ActionsLogMaxRows int
}
// 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"),
HubBaseURL: strings.TrimRight(getenvDefault("HUB_BASE_URL", DefaultHubBaseURL), "/"),
AppriseURL: strings.TrimRight(getenvDefault("APPRISE_URL", DefaultAppriseURL), "/"),
AppriseKey: strings.TrimSpace(os.Getenv("APPRISE_KEY")),
AppriseURLs: splitList(os.Getenv("APPRISE_URLS")),
AppriseToken: os.Getenv("APPRISE_TOKEN"),
SummaryTo: getenvDefault("EMAIL_AGENT_SUMMARY_TO", DefaultSummaryTo),
}
c.HubNotifyTimeout = durationSetting("HUB_NOTIFY_TIMEOUT", DefaultHubNotifyTimout)
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)
c.RetentionDays = getenvDefaultInt("EMAIL_AGENT_RETENTION_DAYS", DefaultRetentionDays)
c.ActionsLogMaxRows = getenvDefaultInt("EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS", DefaultActionsLogMaxRows)
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
}
// splitList parses a comma-separated environment value, trimming blanks.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// durationSetting reads a Go duration setting, falling back to def when unset
// or malformed.
func durationSetting(key string, def time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return def
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return def
}
return d
}
// HubConfigured reports whether the Hub API can serve authenticated requests.
func (c *Config) HubConfigured() bool { return strings.TrimSpace(c.HubToken) != "" }
// AppriseConfigured reports whether a daily summary can be delivered.
func (c *Config) AppriseConfigured() bool {
return strings.TrimSpace(c.AppriseURL) != "" &&
(strings.TrimSpace(c.AppriseKey) != "" || len(c.AppriseURLs) > 0)
}