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:
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user