// Inbox rendering, the manual mail scan, and the classify action. // // Phase 3 groups messages by the live category list (FR-5) and shows the JEV // recommended action plus confidence on each row. Phase 4 adds a bulk-select // toolbar (FR-6) and folder views so a recoverable delete can be seen under the // Delete folder and restored. package server import ( "context" "errors" "fmt" "net/http" "net/url" "sort" "strings" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // unclassifiedCategory is the bucket for messages JEV has not classified yet. const unclassifiedCategory = store.UnclassifiedCategory type messageRow struct { ID int64 From string Subject string SourceAddr string Action string Confidence int Classified bool When string // Restorable is true for a message outside the inbox, so the row offers a // Restore button (FR-9: deletes are recoverable). Restorable bool } type messageGroup struct { Name string Messages []messageRow } type inboxData struct { Groups []messageGroup Total int Note string Error string CSRFToken string Folder string Category string FolderLabel string } // loadInbox reads messages for the active folder/category filter and groups them // by the live category list. func (s *Server) loadInbox(ctx context.Context, f store.MessageFilter) (inboxData, error) { items, err := s.store.ListMessagesFiltered(ctx, f) if err != nil { return inboxData{}, err } categories, err := s.store.ListCategories(ctx) if err != nil { return inboxData{}, err } return inboxData{Groups: groupMessages(items, categories), Total: len(items)}, nil } // groupMessages buckets messages by category, ordering groups the way the // category list is ordered in the UI. Unknown categories (for example a // classification written before a rename) keep their own group; messages with // no classification fall into "Unclassified" last. func groupMessages(items []store.MessageListItem, categories []store.Category) []messageGroup { canonical := make(map[string]string, len(categories)) order := make([]string, 0, len(categories)) for _, c := range categories { canonical[strings.ToLower(c.Name)] = c.Name order = append(order, c.Name) } buckets := map[string][]messageRow{} for _, it := range items { raw := strings.TrimSpace(it.Category) key := "" if raw != "" { if name, ok := canonical[strings.ToLower(raw)]; ok { key = name } else { key = raw } } if key == "" { key = unclassifiedCategory } action := strings.TrimSpace(it.Action) if action == "" { action = "Review" } folder := strings.TrimSpace(it.Folder) if folder == "" { folder = store.FolderInbox } 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, Confidence: confidencePercent(it.Confidence), Classified: it.HasClassification, When: formatWhen(it.DateSent, it.ReceivedAt), Restorable: folder != store.FolderInbox, }) } var keys []string for _, name := range order { if _, ok := buckets[name]; ok { keys = append(keys, name) } } var unknown []string for k := range buckets { if k == unclassifiedCategory || contains(order, k) { continue } unknown = append(unknown, k) } sort.Strings(unknown) keys = append(keys, unknown...) if _, ok := buckets[unclassifiedCategory]; ok { keys = append(keys, unclassifiedCategory) } groups := make([]messageGroup, 0, len(keys)) for _, k := range keys { groups = append(groups, messageGroup{Name: k, Messages: buckets[k]}) } return groups } // inboxSelection reads the active folder/category from the query or the posted // form, applying the inbox default and rejecting unknown values. func inboxSelection(r *http.Request) (folder, category string) { folder = strings.TrimSpace(r.FormValue("folder")) category = strings.TrimSpace(r.FormValue("category")) if folder == "" { folder = store.FolderInbox } if folder != "all" && !store.ValidFolder(folder) { folder = store.FolderInbox } return folder, category } // confidencePercent turns a 0–1 confidence into a whole percentage. func confidencePercent(v float64) int { if v <= 0 { return 0 } if v > 1 { v = 1 } return int(v*100 + 0.5) } func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note, errMsg string) { folder, category := inboxSelection(r) data, err := s.loadInbox(r.Context(), store.MessageFilter{ Folder: folder, Category: category, Unclassified: strings.EqualFold(category, unclassifiedCategory), Limit: 200, }) if err != nil { s.logger.Error("load inbox", "error", err) http.Error(w, "could not load messages", http.StatusInternalServerError) return } data.Note = note data.Error = errMsg data.CSRFToken = s.csrfToken(w, r) data.Folder = folder data.Category = category data.FolderLabel = folderLabel(folder) if category != "" { data.FolderLabel = orDefault(category, data.FolderLabel) } 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(), "") } } // handleClassify classifies pending messages (FR-3) and returns the refreshed // list. A run can take minutes, so the write deadline is extended for this // handler only. func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { s.runClassifier(w, r, false) } // handleReclassify re-runs the classifier over every stored message. It is how // a category-list or rules edit is applied to mail that was already classified. func (s *Server) handleReclassify(w http.ResponseWriter, r *http.Request) { s.runClassifier(w, r, true) } func (s *Server) runClassifier(w http.ResponseWriter, r *http.Request, all bool) { if !s.csrfOK(r) { http.Error(w, "invalid CSRF token", http.StatusForbidden) return } if rc := http.NewResponseController(w); rc != nil { _ = rc.SetWriteDeadline(time.Now().Add(15 * time.Minute)) } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute) defer cancel() var ( sum classify.Summary err error ) if all { sum, err = s.classifier.RunAll(ctx) } else { sum, err = s.classifier.Run(ctx) } switch { case err == nil: s.renderInbox(w, r, fmt.Sprintf( "Classified %d of %d message(s); %d failed; %d still unclassified.", sum.Classified, sum.Considered, sum.Failed, sum.Remaining), "") case errors.Is(err, classify.ErrNotConfigured): s.renderInbox(w, r, "Classification is not connected yet: set JEV_API_KEY.", "") default: s.logger.Error("classification run failed", "error", err) s.renderInbox(w, r, "Classification 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") } // inboxURL builds the /partials/inbox URL for a folder/category view. func inboxURL(folder, category string) string { q := url.Values{} if folder != "" { q.Set("folder", folder) } if category != "" { q.Set("category", category) } if len(q) == 0 { return "/partials/inbox" } return "/partials/inbox?" + q.Encode() }