Phase 2: Gmail IMAP fetch (IPv4), FR-2 source address, real mail in dashboard (AUD-67)

- internal/mailfetch: stdlib-only read-only IMAP client (LOGIN, SELECT,
  UID SEARCH, UID FETCH) that always dials tcp4; IPv6 to Gmail is broken
  from .13 and caused a socket EOF.
- MIME parsing to text, RFC2047 header decoding, FR-2 source address:
  X-Pm-Original-To -> To -> Received 'for <addr>' -> Return-Path, never
  Delivered-To. The matched header is recorded for audit.
- store: Message upsert/list/count + LogAction; schema migration v2 adds
  imap_uid and imap_uidvalidity.
- server: dashboard and /partials/inbox render real mail grouped by
  category with a recommended action; POST /actions/fetch runs a scan
  (CSRF-checked); nav counts; 'Check mail' button.
- config: GMAIL_USER, GMAIL_APP_PASSWORD, IMAP host/port/lookback/max.
- cmd: -fetch runs one scan and exits (n8n/CLI entry point, FR-1).
- compose/.env.example expose the Gmail + IMAP settings.
- tests: FR-2 precedence, MIME bodies, IMAP literal/parser, store
  upsert/migration, inbox rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 20:58:32 +11:00
parent e846400338
commit 1d88f962f6
23 changed files with 2067 additions and 52 deletions

View File

@@ -21,6 +21,13 @@ const (
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.
@@ -65,10 +72,20 @@ type Config struct {
// 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
// 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
@@ -77,20 +94,29 @@ type Config struct {
// 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"),
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
@@ -105,6 +131,33 @@ func Load() (*Config, error) {
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) {
@@ -151,9 +204,21 @@ func (c *Config) validate() 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" }

View File

@@ -7,6 +7,8 @@ func TestLoadDefaults(t *testing.T) {
for _, k := range []string{
"EMAIL_AGENT_ENV", "EMAIL_AGENT_ADDR", "EMAIL_AGENT_PORT",
"EMAIL_AGENT_DB_PATH", "EMAIL_AGENT_LOG_LEVEL", "EMAIL_AGENT_PUBLIC_BASE_URL",
"GMAIL_USER", "GMAIL_APP_PASSWORD", "EMAIL_AGENT_IMAP_HOST", "EMAIL_AGENT_IMAP_PORT",
"EMAIL_AGENT_IMAP_LOOKBACK_DAYS", "EMAIL_AGENT_IMAP_MAX_MESSAGES",
} {
t.Setenv(k, "")
}
@@ -27,6 +29,43 @@ func TestLoadDefaults(t *testing.T) {
if cfg.DBPath != DefaultDB {
t.Errorf("DBPath = %q, want %q", cfg.DBPath, DefaultDB)
}
if cfg.IMAPHost != DefaultIMAPHost {
t.Errorf("IMAPHost = %q, want %q", cfg.IMAPHost, DefaultIMAPHost)
}
if cfg.IMAPPort != DefaultIMAPPort {
t.Errorf("IMAPPort = %d, want %d", cfg.IMAPPort, DefaultIMAPPort)
}
if cfg.IMAPLookbackDays != DefaultIMAPLookbackDays {
t.Errorf("IMAPLookbackDays = %d, want %d", cfg.IMAPLookbackDays, DefaultIMAPLookbackDays)
}
if cfg.IMAPMaxMessages != DefaultIMAPMaxMessages {
t.Errorf("IMAPMaxMessages = %d, want %d", cfg.IMAPMaxMessages, DefaultIMAPMaxMessages)
}
if cfg.GmailConfigured() {
t.Error("GmailConfigured() = true with no credentials")
}
}
func TestGmailConfigured(t *testing.T) {
t.Setenv("GMAIL_USER", "samuelrolfe@gmail.com")
t.Setenv("GMAIL_APP_PASSWORD", "app-password")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.GmailConfigured() {
t.Error("GmailConfigured() = false with credentials set")
}
t.Setenv("GMAIL_APP_PASSWORD", "")
cfg, err = Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.GmailConfigured() {
t.Error("GmailConfigured() = true with no app password")
}
}
func TestLoadOverrides(t *testing.T) {