diff --git a/.env.example b/.env.example index 1f13f52..4e38435 100644 --- a/.env.example +++ b/.env.example @@ -21,11 +21,18 @@ EMAIL_AGENT_SESSION_SECRET=change-me # doubles as the password. EMAIL_AGENT_PASSWORD= +# --- Gmail IMAP (Phase 2) --------------------------------------------------- +# App password only (AGENTS.md §9). Values live in the host environment, never here. +GMAIL_USER= +GMAIL_APP_PASSWORD= +EMAIL_AGENT_IMAP_HOST=imap.gmail.com +EMAIL_AGENT_IMAP_PORT=993 +EMAIL_AGENT_IMAP_LOOKBACK_DAYS=14 +EMAIL_AGENT_IMAP_MAX_MESSAGES=100 + # --- Integrations (used from later phases) --------------------------------- JEV_BASE_URL= JEV_API_KEY= OMNIROUTE_BASE_URL= OMNIROUTE_API_KEY= -EMAIL_AGENT_GMAIL_CREDENTIALS_FILE= -EMAIL_AGENT_GMAIL_TOKEN_FILE= HUB_TOKEN= diff --git a/README.md b/README.md index 486ccbf..5566428 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,20 @@ pre-planning material and is not authoritative. ## Status -**Phase 1.2 — Session login + empty dashboard.** The Go skeleton (Phase 1.1) -plus a single-user session login, a copied Family Home Lab `tokens.css`, and an -HTMX dashboard shell (left nav, centre list, right detail pane) with dark mode. -Mail fetch, classification, and the rest come in later phases. +**Phase 2 — Read mail (IMAP).** The Go skeleton (Phase 1.1) plus session login +and the HTMX dashboard shell (Phase 1.2), now fetching real mail from Gmail over +**IMAP with an app password, forced to IPv4** (AGENTS.md §9). Messages are stored +in SQLite and listed in the dashboard, grouped by category with a recommended +action per row. The FR-2 source address is resolved from `X-Pm-Original-To`, with +`To`, the PurelyMail `Received … for` clause, and `Return-Path` as fallbacks; +`Delivered-To` is never used. Classification and triage actions come in Phase 3+. ## Layout ``` -cmd/email-agent/ service entry point +cmd/email-agent/ service entry point (`-fetch` runs one scan and exits) internal/config/ environment-driven configuration +internal/mailfetch/ stdlib-only IMAP client, MIME parsing, FR-2 source address internal/session/ server-side session store + signed cookie values internal/store/ SQLite open + schema (internal/store/schema.sql) internal/server/ HTTP routes, auth middleware, HTMX handlers @@ -47,6 +51,29 @@ Key variables: | `EMAIL_AGENT_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | | `EMAIL_AGENT_SESSION_SECRET` | _(empty)_ | Session cookie signing key. If unset, an ephemeral key is used (sessions reset on restart) | | `EMAIL_AGENT_PASSWORD` | _(empty)_ | Login password for Sam. If unset, `EMAIL_AGENT_SESSION_SECRET` doubles as the password | +| `GMAIL_USER` | _(empty)_ | Gmail account that receives the forwarded mail | +| `GMAIL_APP_PASSWORD` | _(empty)_ | Gmail app password for IMAP/SMTP (no OAuth) | +| `EMAIL_AGENT_IMAP_HOST` | `imap.gmail.com` | IMAP host | +| `EMAIL_AGENT_IMAP_PORT` | `993` | IMAP port (implicit TLS) | +| `EMAIL_AGENT_IMAP_LOOKBACK_DAYS` | `14` | First-scan window | +| `EMAIL_AGENT_IMAP_MAX_MESSAGES` | `100` | Per-scan message cap | + +## Mail sync (Phase 2) + +`internal/mailfetch` speaks the minimum IMAP needed: `LOGIN`, `SELECT INBOX`, +`UID SEARCH SINCE`, and `UID FETCH` of the full RFC822 source plus Gmail's +`X-GM-MSGID`/`X-GM-THRID`. The dialer uses `tcp4` so Gmail is only ever reached +over IPv4 — IPv6 to Gmail is broken from `.13` and previously caused a socket EOF. + +Trigger a scan either from the dashboard (**Check mail**) or from the CLI: + +```sh +docker exec email-agent email-agent -fetch +``` + +Each scan is bounded by the lookback and max-message settings. Gmail's +`X-GM-MSGID` is the stable message key; the IMAP `UID` and `UIDVALIDITY` are +stored alongside it for later label operations. ## Run locally @@ -76,8 +103,9 @@ Caddy on `.35` proxies `mail.lab.audasmedia.com.au` to this port. | `GET` | `/login` | public | Sign-in form (deep-indigo band) | | `POST` | `/login` | public | CSRF-checked password sign-in, sets the session cookie | | `POST` | `/logout` | session | Destroys the session, returns to `/login` | -| `GET` | `/` | session | Empty dashboard shell | +| `GET` | `/` | session | Dashboard: nav counts, real message list | | `GET` | `/partials/inbox` | session | HTMX fragment for the centre list | +| `POST` | `/actions/fetch` | session + CSRF | Runs one IMAP scan, returns the refreshed list | | `GET` | `/static/…` | public | Embedded `tokens.css`, `app.css`, Inter fonts, `htmx.min.js` | Unauthenticated requests to `/` and `/partials/…` redirect to `/login?next=…`. @@ -90,4 +118,5 @@ SQLite is opened with the pure-Go `modernc.org/sqlite` driver (no CGO). The schema in `internal/store/schema.sql` creates the AGENTS.md §7 tables: `messages`, `classifications`, `rules`, `actions_log`, `unsubscribe`, `drafts`, `summaries`, plus `schema_migrations`. Schema init is idempotent and runs on -every start. +every start. Migration v2 adds `imap_uid` and `imap_uidvalidity` to `messages` +for databases created by Phase 1. diff --git a/cmd/email-agent/main.go b/cmd/email-agent/main.go index f27ba1f..f8b4a6d 100644 --- a/cmd/email-agent/main.go +++ b/cmd/email-agent/main.go @@ -13,6 +13,7 @@ import ( "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/server" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/version" @@ -22,6 +23,7 @@ func main() { var ( showVersion = flag.Bool("version", false, "print version information and exit") healthcheck = flag.Bool("healthcheck", false, "probe the local /health endpoint and exit") + fetchNow = flag.Bool("fetch", false, "run one Gmail IMAP scan and exit") ) flag.Parse() @@ -47,6 +49,14 @@ func main() { return } + if *fetchNow { + if err := runFetch(cfg, logger); err != nil { + fmt.Fprintf(os.Stderr, "fetch failed: %v\n", err) + os.Exit(1) + } + return + } + if err := run(cfg, logger); err != nil { logger.Error("fatal", "error", err) os.Exit(1) @@ -91,6 +101,30 @@ func run(cfg *config.Config, logger *slog.Logger) error { return srv.Run(ctx) } +// runFetch performs a single IMAP scan and exits. It is the entry point n8n +// (FR-1) and manual operations use to trigger a sync without the HTTP UI. +func runFetch(cfg *config.Config, logger *slog.Logger) error { + if !cfg.GmailConfigured() { + return mailfetch.ErrNoCredentials + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + st, err := store.Open(ctx, cfg.DBPath) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer func() { _ = st.Close() }() + + sum, err := mailfetch.NewFetcher(cfg, st, logger).Fetch(ctx) + if err != nil { + return err + } + fmt.Printf("fetched=%d stored=%d uidvalidity=%d since=%s\n", + sum.Fetched, sum.Stored, sum.UIDValidity, sum.Since.Format("2006-01-02")) + return nil +} + // probeHealth requests the local health endpoint. It exists so the container // healthcheck does not need an external HTTP client in the image. func probeHealth(port int) error { diff --git a/docker-compose.yml b/docker-compose.yml index 57a0a94..ceeb0a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,12 +31,17 @@ services: # and are never committed. EMAIL_AGENT_SESSION_SECRET: ${EMAIL_AGENT_SESSION_SECRET:-} EMAIL_AGENT_PASSWORD: ${EMAIL_AGENT_PASSWORD:-} + # Gmail IMAP + app password (AGENTS.md §8/§9). The dialer forces IPv4. + GMAIL_USER: ${GMAIL_USER:-} + GMAIL_APP_PASSWORD: ${GMAIL_APP_PASSWORD:-} + EMAIL_AGENT_IMAP_HOST: ${EMAIL_AGENT_IMAP_HOST:-imap.gmail.com} + EMAIL_AGENT_IMAP_PORT: ${EMAIL_AGENT_IMAP_PORT:-993} + EMAIL_AGENT_IMAP_LOOKBACK_DAYS: ${EMAIL_AGENT_IMAP_LOOKBACK_DAYS:-14} + EMAIL_AGENT_IMAP_MAX_MESSAGES: ${EMAIL_AGENT_IMAP_MAX_MESSAGES:-100} JEV_BASE_URL: ${JEV_BASE_URL:-} JEV_API_KEY: ${JEV_API_KEY:-} OMNIROUTE_BASE_URL: ${OMNIROUTE_BASE_URL:-} OMNIROUTE_API_KEY: ${OMNIROUTE_API_KEY:-} - EMAIL_AGENT_GMAIL_CREDENTIALS_FILE: ${EMAIL_AGENT_GMAIL_CREDENTIALS_FILE:-} - EMAIL_AGENT_GMAIL_TOKEN_FILE: ${EMAIL_AGENT_GMAIL_TOKEN_FILE:-} HUB_TOKEN: ${HUB_TOKEN:-} volumes: diff --git a/internal/config/config.go b/internal/config/config.go index a19e371..7948938 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3b11c4e..a3065af 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { diff --git a/internal/mailfetch/fetch.go b/internal/mailfetch/fetch.go new file mode 100644 index 0000000..786fc37 --- /dev/null +++ b/internal/mailfetch/fetch.go @@ -0,0 +1,160 @@ +package mailfetch + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + "sync" + "time" + + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" +) + +// ErrNoCredentials means IMAP login cannot be attempted. +var ErrNoCredentials = errors.New("GMAIL_USER and GMAIL_APP_PASSWORD are not configured") + +// ErrAlreadyRunning means a scan is already in progress. +var ErrAlreadyRunning = errors.New("a mail scan is already running") + +// Fetcher performs an IMAP scan and stores the results. +type Fetcher struct { + cfg *config.Config + store *store.Store + logger *slog.Logger + + mu sync.Mutex +} + +// NewFetcher builds a Fetcher. +func NewFetcher(cfg *config.Config, st *store.Store, logger *slog.Logger) *Fetcher { + return &Fetcher{cfg: cfg, store: st, logger: logger} +} + +// Summary describes the outcome of one scan. +type Summary struct { + UIDValidity uint32 + Since time.Time + Fetched int + Stored int + Duration time.Duration +} + +// Fetch performs one bounded IMAP scan of INBOX and upserts the results. +func (f *Fetcher) Fetch(ctx context.Context) (Summary, error) { + if !f.cfg.GmailConfigured() { + return Summary{}, ErrNoCredentials + } + if !f.mu.TryLock() { + return Summary{}, ErrAlreadyRunning + } + defer f.mu.Unlock() + + start := time.Now() + client, err := Dial(ctx, f.cfg.IMAPHost, f.cfg.IMAPPort, DefaultTimeout) + if err != nil { + return Summary{}, err + } + defer func() { _ = client.Logout() }() + + if err := client.Login(f.cfg.GmailUser, f.cfg.GmailAppPassword); err != nil { + return Summary{}, err + } + + uidValidity, err := client.Select("INBOX") + if err != nil { + return Summary{}, err + } + + since := time.Now().UTC().AddDate(0, 0, -f.cfg.IMAPLookbackDays) + uids, err := client.SearchSince(since) + if err != nil { + return Summary{}, err + } + if max := f.cfg.IMAPMaxMessages; max > 0 && len(uids) > max { + // Keep the newest; SEARCH returns ascending UIDs. + uids = uids[len(uids)-max:] + } + + results, err := client.Fetch(uids) + if err != nil { + return Summary{}, err + } + + sum := Summary{ + UIDValidity: uidValidity, + Since: since, + Fetched: len(results), + } + now := time.Now().UTC() + headersSeen := map[string]struct{}{} + for _, r := range results { + parsed, perr := ParseMessage(r.Raw) + if perr != nil { + f.logger.Warn("skip unparsable message", "uid", r.UID, "error", perr) + continue + } + if parsed.SourceHeader != "" { + headersSeen[parsed.SourceHeader] = struct{}{} + } + msg := &store.Message{ + GmailID: gmailID(r, uidValidity), + ThreadID: r.GmailThreadID, + FromAddr: parsed.From, + ToAddr: parsed.To, + Subject: parsed.Subject, + BodyText: parsed.BodyText, + SourceAddress: parsed.SourceAddress, + DateSent: parsed.Date, + ReceivedAt: now, + IMAPUID: int64(r.UID), + IMAPUIDValidity: int64(uidValidity), + } + if _, err := f.store.UpsertMessage(ctx, msg); err != nil { + return sum, err + } + sum.Stored++ + } + sum.Duration = time.Since(start) + + if err := f.store.LogAction(ctx, "fetch", "agent", + fmt.Sprintf("uidvalidity=%d since=%s fetched=%d stored=%d source_headers=%s", + uidValidity, since.Format("2006-01-02"), sum.Fetched, sum.Stored, joinKeys(headersSeen))); err != nil { + f.logger.Warn("record fetch action", "error", err) + } + + f.logger.Info("mail scan complete", + "uidvalidity", uidValidity, + "since", since.Format("2006-01-02"), + "fetched", sum.Fetched, + "stored", sum.Stored, + "duration_ms", sum.Duration.Milliseconds(), + ) + return sum, nil +} + +// gmailID prefers Gmail's stable X-GM-MSGID and falls back to a UID key that is +// scoped to the UIDVALIDITY it was issued under. +func gmailID(r FetchResult, uidValidity uint32) string { + if r.GmailMsgID != "" { + return r.GmailMsgID + } + return "uid:" + strconv.FormatUint(uint64(uidValidity), 10) + ":" + strconv.FormatUint(uint64(r.UID), 10) +} + +// joinKeys returns the sorted keys of a string set, for a log line. +func joinKeys(set map[string]struct{}) string { + if len(set) == 0 { + return "none" + } + parts := make([]string, 0, len(set)) + for k := range set { + parts = append(parts, k) + } + slices.Sort(parts) + return strings.Join(parts, ",") +} diff --git a/internal/mailfetch/fetch_test.go b/internal/mailfetch/fetch_test.go new file mode 100644 index 0000000..0403a78 --- /dev/null +++ b/internal/mailfetch/fetch_test.go @@ -0,0 +1,40 @@ +package mailfetch + +import ( + "context" + "errors" + "log/slog" + "testing" + + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" +) + +func TestGmailIDPrefersXGMMsgID(t *testing.T) { + if got := gmailID(FetchResult{UID: 7, GmailMsgID: "555"}, 42); got != "555" { + t.Errorf("gmailID() = %q, want 555", got) + } +} + +func TestGmailIDFallbackIsScopedToUIDValidity(t *testing.T) { + if got := gmailID(FetchResult{UID: 7}, 42); got != "uid:42:7" { + t.Errorf("gmailID() = %q, want uid:42:7", got) + } +} + +func TestJoinKeys(t *testing.T) { + if got := joinKeys(map[string]struct{}{}); got != "none" { + t.Errorf("joinKeys(empty) = %q, want none", got) + } + got := joinKeys(map[string]struct{}{"To": {}, "X-Pm-Original-To": {}}) + if got != "To,X-Pm-Original-To" { + t.Errorf("joinKeys() = %q, want sorted keys", got) + } +} + +func TestFetchWithoutCredentials(t *testing.T) { + cfg := &config.Config{Env: "test", IMAPHost: config.DefaultIMAPHost, IMAPPort: config.DefaultIMAPPort} + f := NewFetcher(cfg, nil, slog.New(slog.DiscardHandler)) + if _, err := f.Fetch(context.Background()); !errors.Is(err, ErrNoCredentials) { + t.Fatalf("Fetch() error = %v, want ErrNoCredentials", err) + } +} diff --git a/internal/mailfetch/imap.go b/internal/mailfetch/imap.go new file mode 100644 index 0000000..ea14b5b --- /dev/null +++ b/internal/mailfetch/imap.go @@ -0,0 +1,328 @@ +// Package mailfetch reads mail from Gmail over IMAP and turns it into stored +// messages. +// +// The IMAP client is intentionally dependency-free: it implements only the +// subset the Email Agent needs (LOGIN, SELECT, UID SEARCH, UID FETCH) and it +// always dials IPv4. IPv6 to Gmail is broken from .13 and previously caused a +// socket EOF (AGENTS.md §9). +package mailfetch + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "regexp" + "strconv" + "strings" + "time" +) + +// DefaultTimeout bounds a single IMAP read/write. +const DefaultTimeout = 60 * time.Second + +// Client is a minimal read-only IMAP client. +type Client struct { + conn net.Conn + br *bufio.Reader + tagSeq int + timeout time.Duration + preAuth bool +} + +// response is one IMAP response: the assembled text (literals removed) plus any +// literal payloads in order of appearance. +type response struct { + line string + literals [][]byte +} + +// ServerError is a tagged NO/BAD completion from the IMAP server. +type ServerError struct { + Command string + Response string +} + +func (e *ServerError) Error() string { + return fmt.Sprintf("imap %s failed: %s", e.Command, e.Response) +} + +// Dial opens an implicit-TLS IMAP connection to host:port, forcing IPv4. +func Dial(ctx context.Context, host string, port int, timeout time.Duration) (*Client, error) { + if timeout <= 0 { + timeout = DefaultTimeout + } + addr := net.JoinHostPort(host, strconv.Itoa(port)) + dialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second} + raw, err := dialer.DialContext(ctx, "tcp4", addr) + if err != nil { + return nil, fmt.Errorf("dial %s over IPv4: %w", addr, err) + } + + tlsConn := tls.Client(raw, &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + }) + hsCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if err := tlsConn.HandshakeContext(hsCtx); err != nil { + _ = raw.Close() + return nil, fmt.Errorf("tls handshake with %s: %w", addr, err) + } + + c := &Client{conn: tlsConn, br: bufio.NewReader(tlsConn), timeout: timeout} + + greeting, err := c.readResponse() + if err != nil { + _ = tlsConn.Close() + return nil, fmt.Errorf("read IMAP greeting: %w", err) + } + upper := strings.ToUpper(greeting.line) + if strings.HasPrefix(upper, "* PREAUTH") { + c.preAuth = true + return c, nil + } + if !strings.HasPrefix(upper, "* OK") { + _ = tlsConn.Close() + return nil, fmt.Errorf("unexpected IMAP greeting: %s", greeting.line) + } + return c, nil +} + +// Login authenticates with an app password. +func (c *Client) Login(user, pass string) error { + if c.preAuth { + return nil + } + return c.exec("LOGIN "+imapQuote(user)+" "+imapQuote(pass), nil) +} + +// Select opens a mailbox and returns its UIDVALIDITY. +func (c *Client) Select(mailbox string) (uint32, error) { + var uidValidity uint32 + err := c.exec("SELECT "+imapQuote(mailbox), func(r *response) error { + if v, ok := parseUIDValidity(r.line); ok { + uidValidity = v + } + return nil + }) + return uidValidity, err +} + +// SearchSince returns the UIDs of messages with an internal date on or after t. +func (c *Client) SearchSince(t time.Time) ([]uint32, error) { + date := t.UTC().Format("02-Jan-2006") + var uids []uint32 + err := c.exec("UID SEARCH SINCE "+date, func(r *response) error { + if !strings.HasPrefix(strings.ToUpper(r.line), "* SEARCH") { + return nil + } + fields := strings.Fields(r.line) + for _, f := range fields[2:] { + if n, err := strconv.ParseUint(f, 10, 32); err == nil { + uids = append(uids, uint32(n)) + } + } + return nil + }) + return uids, err +} + +// FetchResult is one message returned by Fetch. +type FetchResult struct { + UID uint32 + GmailMsgID string + GmailThreadID string + Raw []byte +} + +// fetchChunk bounds how many UIDs go into a single FETCH command. +const fetchChunk = 20 + +// Fetch retrieves the full RFC822 source for each UID, in chunks. +func (c *Client) Fetch(uids []uint32) ([]FetchResult, error) { + var out []FetchResult + for start := 0; start < len(uids); start += fetchChunk { + end := start + fetchChunk + if end > len(uids) { + end = len(uids) + } + set := uidSet(uids[start:end]) + var batch []FetchResult + err := c.exec("UID FETCH "+set+" (UID X-GM-MSGID X-GM-THRID BODY.PEEK[])", func(r *response) error { + if fr, ok := parseFetch(r); ok { + batch = append(batch, fr) + } + return nil + }) + if err != nil { + return out, err + } + out = append(out, batch...) + } + return out, nil +} + +// Logout sends LOGOUT and closes the connection. +func (c *Client) Logout() error { + err := c.exec("LOGOUT", nil) + _ = c.conn.Close() + return err +} + +// Close closes the connection without a graceful logout. +func (c *Client) Close() error { + if c == nil || c.conn == nil { + return nil + } + return c.conn.Close() +} + +// --- protocol plumbing ------------------------------------------------------ + +func (c *Client) exec(command string, onUntagged func(*response) error) error { + c.tagSeq++ + tag := fmt.Sprintf("A%04d", c.tagSeq) + if err := c.writeLine(tag + " " + command); err != nil { + return err + } + for { + resp, err := c.readResponse() + if err != nil { + return err + } + switch { + case strings.HasPrefix(resp.line, tag+" "): + status := strings.TrimSpace(strings.TrimPrefix(resp.line, tag+" ")) + if strings.HasPrefix(strings.ToUpper(status), "OK") { + return nil + } + return &ServerError{Command: command, Response: status} + case strings.HasPrefix(resp.line, "* "): + if onUntagged != nil { + if err := onUntagged(resp); err != nil { + return err + } + } + case strings.HasPrefix(resp.line, "+ "): + return fmt.Errorf("imap server requested a continuation, which this client does not support: %s", resp.line) + default: + // Ignore unexpected non-tagged lines rather than desynchronising. + } + } +} + +func (c *Client) writeLine(s string) error { + if err := c.conn.SetWriteDeadline(time.Now().Add(c.timeout)); err != nil { + return err + } + if _, err := c.conn.Write([]byte(s + "\r\n")); err != nil { + return fmt.Errorf("imap write: %w", err) + } + return nil +} + +// readResponse reads one logical IMAP response, resolving literals. +func (c *Client) readResponse() (*response, error) { + var sb strings.Builder + var lits [][]byte + for { + line, err := c.readLine() + if err != nil { + return nil, err + } + if n, ok := literalLen(line); ok { + sb.WriteString(line[:strings.LastIndexByte(line, '{')]) + buf := make([]byte, n) + if _, err := io.ReadFull(c.br, buf); err != nil { + return nil, fmt.Errorf("read literal: %w", err) + } + lits = append(lits, buf) + continue + } + sb.WriteString(line) + return &response{line: sb.String(), literals: lits}, nil + } +} + +func (c *Client) readLine() (string, error) { + if err := c.conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil { + return "", err + } + line, err := c.br.ReadString('\n') + if err != nil && !(err == io.EOF && line != "") { + return "", fmt.Errorf("imap read: %w", err) + } + return strings.TrimRight(line, "\r\n"), nil +} + +// literalLen reports the byte count of a trailing `{n}` literal marker. +func literalLen(line string) (int, bool) { + if !strings.HasSuffix(line, "}") { + return 0, false + } + i := strings.LastIndexByte(line, '{') + if i < 0 { + return 0, false + } + n, err := strconv.Atoi(line[i+1 : len(line)-1]) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +var ( + uidValidityRE = regexp.MustCompile(`(?i)\[UIDVALIDITY (\d+)\]`) + fetchUIDRE = regexp.MustCompile(`(?i)(?:^|[\s(])UID (\d+)`) + fetchMsgIDRE = regexp.MustCompile(`(?i)(?:^|[\s(])X-GM-MSGID (\d+)`) + fetchThrIDRE = regexp.MustCompile(`(?i)(?:^|[\s(])X-GM-THRID (\d+)`) +) + +func parseUIDValidity(line string) (uint32, bool) { + m := uidValidityRE.FindStringSubmatch(line) + if m == nil { + return 0, false + } + n, err := strconv.ParseUint(m[1], 10, 32) + if err != nil { + return 0, false + } + return uint32(n), true +} + +func parseFetch(r *response) (FetchResult, bool) { + if len(r.literals) == 0 || !strings.Contains(strings.ToUpper(r.line), "FETCH") { + return FetchResult{}, false + } + fr := FetchResult{Raw: r.literals[0]} + if m := fetchUIDRE.FindStringSubmatch(r.line); m != nil { + if n, err := strconv.ParseUint(m[1], 10, 32); err == nil { + fr.UID = uint32(n) + } + } + if m := fetchMsgIDRE.FindStringSubmatch(r.line); m != nil { + fr.GmailMsgID = m[1] + } + if m := fetchThrIDRE.FindStringSubmatch(r.line); m != nil { + fr.GmailThreadID = m[1] + } + return fr, true +} + +func uidSet(uids []uint32) string { + parts := make([]string, len(uids)) + for i, u := range uids { + parts[i] = strconv.FormatUint(uint64(u), 10) + } + return strings.Join(parts, ",") +} + +// imapQuote quotes a string as an IMAP quoted-string. +func imapQuote(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} diff --git a/internal/mailfetch/imap_test.go b/internal/mailfetch/imap_test.go new file mode 100644 index 0000000..20933fd --- /dev/null +++ b/internal/mailfetch/imap_test.go @@ -0,0 +1,167 @@ +package mailfetch + +import ( + "bufio" + "bytes" + "errors" + "net" + "strings" + "testing" + "time" +) + +// fakeConn is an in-memory net.Conn for protocol tests. +type fakeConn struct { + r *bytes.Reader + w bytes.Buffer +} + +func newFakeConn(script string) *fakeConn { + return &fakeConn{r: bytes.NewReader([]byte(script))} +} + +func (c *fakeConn) Read(p []byte) (int, error) { return c.r.Read(p) } +func (c *fakeConn) Write(p []byte) (int, error) { return c.w.Write(p) } +func (c *fakeConn) Close() error { return nil } +func (c *fakeConn) LocalAddr() net.Addr { return dummyAddr{} } +func (c *fakeConn) RemoteAddr() net.Addr { return dummyAddr{} } +func (c *fakeConn) SetDeadline(time.Time) error { return nil } +func (c *fakeConn) SetReadDeadline(time.Time) error { + return nil +} +func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil } + +type dummyAddr struct{} + +func (dummyAddr) Network() string { return "fake" } +func (dummyAddr) String() string { return "fake" } + +func newFakeClient(script string) (*Client, *fakeConn) { + fc := newFakeConn(script) + return &Client{conn: fc, br: bufio.NewReader(fc), timeout: time.Second}, fc +} + +func TestReadResponseSingleLiteral(t *testing.T) { + script := "* 1 FETCH (UID 10 BODY[] {5}\r\nHELLO)\r\n" + c, _ := newFakeClient(script) + + resp, err := c.readResponse() + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + if len(resp.literals) != 1 || string(resp.literals[0]) != "HELLO" { + t.Fatalf("literals = %q, want [HELLO]", resp.literals) + } + if !strings.Contains(resp.line, "UID 10") || !strings.Contains(resp.line, ")") { + t.Errorf("assembled line = %q, want UID and closing paren", resp.line) + } +} + +func TestReadResponseLiteralAtEnd(t *testing.T) { + script := "* 1 FETCH (BODY[] {5}\r\nHELLO\r\n\r\n" + c, _ := newFakeClient(script) + + resp, err := c.readResponse() + if err != nil { + t.Fatalf("readResponse() error = %v", err) + } + if len(resp.literals) != 1 || string(resp.literals[0]) != "HELLO" { + t.Fatalf("literals = %q, want [HELLO]", resp.literals) + } +} + +func TestSearchSince(t *testing.T) { + script := "* SEARCH 10 11 12\r\nA0001 OK SEARCH completed\r\n" + c, fc := newFakeClient(script) + + uids, err := c.SearchSince(time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("SearchSince() error = %v", err) + } + want := []uint32{10, 11, 12} + if len(uids) != len(want) { + t.Fatalf("uids = %v, want %v", uids, want) + } + for i := range want { + if uids[i] != want[i] { + t.Errorf("uids[%d] = %d, want %d", i, uids[i], want[i]) + } + } + if !strings.Contains(fc.w.String(), "UID SEARCH SINCE 02-Jan-2026") { + t.Errorf("command sent = %q, want UID SEARCH SINCE 02-Jan-2026", fc.w.String()) + } +} + +func TestFetchParsesGmailIDs(t *testing.T) { + script := "* 1 FETCH (UID 10 X-GM-MSGID 1234000000000000000 X-GM-THRID 999 BODY[] {5}\r\nHELLO)\r\n" + + "A0001 OK FETCH completed\r\n" + c, _ := newFakeClient(script) + + results, err := c.Fetch([]uint32{10}) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + if len(results) != 1 { + t.Fatalf("results = %d, want 1", len(results)) + } + if results[0].UID != 10 { + t.Errorf("UID = %d, want 10", results[0].UID) + } + if results[0].GmailMsgID != "1234000000000000000" { + t.Errorf("GmailMsgID = %q", results[0].GmailMsgID) + } + if results[0].GmailThreadID != "999" { + t.Errorf("GmailThreadID = %q", results[0].GmailThreadID) + } + if string(results[0].Raw) != "HELLO" { + t.Errorf("Raw = %q, want HELLO", results[0].Raw) + } +} + +func TestLoginFailureIsServerError(t *testing.T) { + script := "* OK greeting\r\nA0001 NO [AUTHENTICATIONFAILED] Invalid credentials\r\n" + c, _ := newFakeClient(script) + + err := c.Login("user@example.test", "wrong") + if err == nil { + t.Fatal("Login() error = nil, want failure") + } + var se *ServerError + if !errors.As(err, &se) { + t.Fatalf("Login() error = %T, want *ServerError", err) + } + if !strings.Contains(se.Response, "AUTHENTICATIONFAILED") { + t.Errorf("ServerError.Response = %q", se.Response) + } +} + +func TestLiteralLen(t *testing.T) { + cases := []struct { + in string + n int + ok bool + }{ + {"* 1 FETCH (BODY[] {12}", 12, true}, + {"* 1 FETCH (BODY[] {abc}", 0, false}, + {"* 1 FETCH (BODY[])", 0, false}, + {"* SEARCH 1 2", 0, false}, + } + for _, tc := range cases { + n, ok := literalLen(tc.in) + if n != tc.n || ok != tc.ok { + t.Errorf("literalLen(%q) = (%d, %v), want (%d, %v)", tc.in, n, ok, tc.n, tc.ok) + } + } +} + +func TestImapQuote(t *testing.T) { + if got := imapQuote(`pa"ss\word`); got != `"pa\"ss\\word"` { + t.Errorf("imapQuote() = %q", got) + } +} + +func TestUIDSet(t *testing.T) { + if got := uidSet([]uint32{1, 2, 3}); got != "1,2,3" { + t.Errorf("uidSet() = %q, want 1,2,3", got) + } +} diff --git a/internal/mailfetch/message.go b/internal/mailfetch/message.go new file mode 100644 index 0000000..34a579a --- /dev/null +++ b/internal/mailfetch/message.go @@ -0,0 +1,249 @@ +package mailfetch + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "net/textproto" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// maxBodyBytes caps how much body text is read from one message. +const maxBodyBytes = 1 << 20 // 1 MiB + +// ParsedMessage is the subset of an RFC822 message the agent stores. +type ParsedMessage struct { + From string + To string + Subject string + Date time.Time + SourceAddress string + // SourceHeader records which header the source address came from, so FR-2 + // decisions are auditable. + SourceHeader string + BodyText string +} + +// ParseMessage parses a raw RFC822 message. +func ParseMessage(raw []byte) (*ParsedMessage, error) { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("parse RFC822 message: %w", err) + } + h := msg.Header + pm := &ParsedMessage{ + From: decodeHeader(h.Get("From")), + To: decodeHeader(h.Get("To")), + Subject: decodeHeader(h.Get("Subject")), + Date: parseDate(h.Get("Date")), + } + pm.SourceAddress, pm.SourceHeader = SourceAddress(textproto.MIMEHeader(h)) + pm.BodyText = extractBody(textproto.MIMEHeader(h), msg.Body) + return pm, nil +} + +// SourceAddress resolves the address the mail arrived at (FR-2). +// +// Order: +// 1. X-Pm-Original-To (added by PurelyMail) +// 2. To +// 3. the PurelyMail "Received ... for
" clause +// 4. Return-Path +// +// Delivered-To is deliberately never used: Gmail overwrites it. +func SourceAddress(h textproto.MIMEHeader) (address, header string) { + if v := headerAddress(h.Get("X-Pm-Original-To")); v != "" { + return v, "X-Pm-Original-To" + } + if v := headerAddress(h.Get("To")); v != "" { + return v, "To" + } + for _, rv := range h.Values("Received") { + if v := receivedFor(rv); v != "" { + return v, "Received" + } + } + if v := headerAddress(h.Get("Return-Path")); v != "" { + return v, "Return-Path" + } + return "", "" +} + +// headerAddress returns the first address in a header value. +func headerAddress(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if list, err := mail.ParseAddressList(v); err == nil && len(list) > 0 { + return strings.TrimSpace(list[0].Address) + } + if a, err := mail.ParseAddress(v); err == nil { + return strings.TrimSpace(a.Address) + } + v = strings.TrimSpace(strings.TrimPrefix(v, "<")) + if i := strings.IndexAny(v, ">,;"); i >= 0 { + v = v[:i] + } + return strings.Trim(strings.TrimSpace(v), "<>") +} + +var receivedForRE = regexp.MustCompile(`(?i)\bfor\s+([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+[A-Za-z0-9])>?`) + +// receivedFor extracts the address from a "Received: ... forhtml part loses
", + "--BOUND--", + "", + }, "\r\n") + + pm, err := ParseMessage([]byte(raw)) + if err != nil { + t.Fatalf("ParseMessage() error = %v", err) + } + if !strings.Contains(pm.BodyText, "plain part wins") { + t.Errorf("BodyText = %q, want the text/plain part", pm.BodyText) + } + if strings.Contains(pm.BodyText, "html part") { + t.Errorf("BodyText = %q, must not contain the HTML part", pm.BodyText) + } +} + +func TestParseMessageHTMLOnlyIsStripped(t *testing.T) { + raw := strings.Join([]string{ + "From: sender@example.test", + "Subject: html only", + "Content-Type: text/html; charset=utf-8", + "", + "World & friends
", + }, "\r\n") + + pm, err := ParseMessage([]byte(raw)) + if err != nil { + t.Fatalf("ParseMessage() error = %v", err) + } + if !strings.Contains(pm.BodyText, "Hello") || !strings.Contains(pm.BodyText, "World & friends") { + t.Errorf("BodyText = %q, want stripped HTML text", pm.BodyText) + } + if strings.Contains(pm.BodyText, "<") { + t.Errorf("BodyText = %q, want no tags", pm.BodyText) + } +} + +func TestHeaderAddressParsing(t *testing.T) { + cases := []struct{ in, want string }{ + {"Hello@where-woof.com", "Hello@where-woof.com"}, + {"