// Inbox rendering and the manual mail scan. // // Phase 2 lists real mail from the database. Classification is Phase 3, so // messages without a classification are shown under "Unclassified" with a // temporary "Review" recommended action; the grouping and action plumbing is // already category-aware. package server import ( "context" "errors" "fmt" "net/http" "sort" "strings" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // unclassifiedCategory is the fallback bucket before Phase 3 adds JEV // categories (AGENTS.md ยง13.2). const unclassifiedCategory = "unclassified" // categoryOrder is the display order for the built-in categories. var categoryOrder = []string{"action", "important", "info", "spam", "newsletter", "auto-reply"} var categoryDisplay = map[string]string{ "action": "Action", "important": "Important", "info": "Info", "spam": "Spam", "newsletter": "Newsletter", "auto-reply": "Auto-reply", "unclassified": "Unclassified", } type messageRow struct { ID int64 From string Subject string SourceAddr string Action string When string } type messageGroup struct { Name string Messages []messageRow } type inboxData struct { Groups []messageGroup Total int Note string Error string } // loadInbox reads messages and groups them for rendering. func (s *Server) loadInbox(ctx context.Context) (inboxData, error) { items, err := s.store.ListMessages(ctx, 200) if err != nil { return inboxData{}, err } return inboxData{Groups: groupMessages(items), Total: len(items)}, nil } // groupMessages buckets messages by category. Unknown (custom) categories get // their own group; messages with no classification fall into "Unclassified". func groupMessages(items []store.MessageListItem) []messageGroup { buckets := map[string][]messageRow{} for _, it := range items { key := strings.ToLower(strings.TrimSpace(it.Category)) if key == "" { key = unclassifiedCategory } action := strings.TrimSpace(it.Action) if action == "" { action = "Review" } buckets[key] = append(buckets[key], messageRow{ ID: it.ID, From: orDefault(it.FromAddr, "(unknown sender)"), Subject: orDefault(it.Subject, "(no subject)"), SourceAddr: it.SourceAddress, Action: action, When: formatWhen(it.DateSent, it.ReceivedAt), }) } // Order: built-in categories first, then custom ones alphabetically, then // Unclassified last. var keys []string for _, k := range categoryOrder { if _, ok := buckets[k]; ok { keys = append(keys, k) } } var custom []string for k := range buckets { if k == unclassifiedCategory || contains(categoryOrder, k) { continue } custom = append(custom, k) } sort.Strings(custom) keys = append(keys, custom...) if _, ok := buckets[unclassifiedCategory]; ok { keys = append(keys, unclassifiedCategory) } groups := make([]messageGroup, 0, len(keys)) for _, k := range keys { groups = append(groups, messageGroup{ Name: displayCategory(k), Messages: buckets[k], }) } return groups } func displayCategory(key string) string { if name, ok := categoryDisplay[key]; ok { return name } if key == "" { return "Unclassified" } return strings.ToUpper(key[:1]) + key[1:] } // categoryCounts returns per-category message counts for the left nav. func categoryCounts(items []store.MessageListItem) map[string]int { counts := map[string]int{} for _, it := range items { key := strings.ToLower(strings.TrimSpace(it.Category)) if key == "" { key = unclassifiedCategory } counts[key]++ } return counts } func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) { data, err := s.loadInbox(r.Context()) if err != nil { s.logger.Error("load inbox", "error", err) http.Error(w, "could not load messages", http.StatusInternalServerError) return } data.Note = note w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "inbox.html", data); err != nil { s.logger.Error("render inbox partial", "error", err) } } // handleInboxPartial is the HTMX fragment for the centre list. func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) { s.renderInbox(w, r, "") } // handleFetchMail runs one IMAP scan, then returns the refreshed list. It is a // POST because it changes stored state and must carry a CSRF token. func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() sum, err := s.fetcher.Fetch(ctx) switch { case err == nil: s.renderInbox(w, r, fmt.Sprintf("Scanned %d message(s); %d stored or refreshed.", sum.Fetched, sum.Stored)) case errors.Is(err, mailfetch.ErrNoCredentials): s.renderInbox(w, r, "Mail is not connected yet: set GMAIL_USER and GMAIL_APP_PASSWORD.") case errors.Is(err, mailfetch.ErrAlreadyRunning): s.renderInbox(w, r, "A mail scan is already running.") default: s.logger.Error("mail scan failed", "error", err) s.renderInbox(w, r, "Mail scan failed: "+err.Error()) } } func orDefault(v, def string) string { if strings.TrimSpace(v) == "" { return def } return strings.TrimSpace(v) } func contains(list []string, v string) bool { for _, x := range list { if x == v { return true } } return false } // formatWhen renders a compact, timezone-stable timestamp. func formatWhen(sent, received time.Time) string { t := sent if t.IsZero() { t = received } if t.IsZero() { return "" } if t.Year() == time.Now().Year() { return t.Format("2 Jan 15:04") } return t.Format("2 Jan 2006") }