Bulk select in the triage list, with delete/move/mark and a recoverable delete (FR-6, FR-9). - migration v4: messages.folder + messages.mailbox; folder/category views - internal/mailops: app folder -> Gmail label moves over IMAP (IPv4), locate by X-GM-MSGID, create labels lazily, move/save/reply/restore, flag marks - mailfetch: CREATE, UID MOVE (COPY+EXPUNGE fallback), STORE flags, X-GM-MSGID search - server: POST /actions/bulk with CSRF, UI delete confirmation, second confirmation for messages classified Spam (never auto-deleted), per-message and summary action-log rows (FR-13) - inbox: select-all toolbar, checkboxes, folder nav, Restore on non-inbox rows Co-Authored-By: Paperclip <noreply@paperclip.ing>
319 lines
8.8 KiB
Go
319 lines
8.8 KiB
Go
// 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()
|
||
}
|