Phase 4: triage actions (delete, move, mark; bulk select) (AUD-70)
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>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
// 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. Messages without a
|
||||
// classification fall under "Unclassified".
|
||||
// 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 (
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// unclassifiedCategory is the bucket for messages JEV has not classified yet.
|
||||
const unclassifiedCategory = "Unclassified"
|
||||
const unclassifiedCategory = store.UnclassifiedCategory
|
||||
|
||||
type messageRow struct {
|
||||
ID int64
|
||||
@@ -31,6 +33,9 @@ type messageRow struct {
|
||||
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 {
|
||||
@@ -39,15 +44,20 @@ type messageGroup struct {
|
||||
}
|
||||
|
||||
type inboxData struct {
|
||||
Groups []messageGroup
|
||||
Total int
|
||||
Note string
|
||||
Error string
|
||||
Groups []messageGroup
|
||||
Total int
|
||||
Note string
|
||||
Error string
|
||||
CSRFToken string
|
||||
Folder string
|
||||
Category string
|
||||
FolderLabel string
|
||||
}
|
||||
|
||||
// loadInbox reads messages and groups them by the live category list.
|
||||
func (s *Server) loadInbox(ctx context.Context) (inboxData, error) {
|
||||
items, err := s.store.ListMessages(ctx, 200)
|
||||
// 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
|
||||
}
|
||||
@@ -88,6 +98,10 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
||||
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)"),
|
||||
@@ -97,6 +111,7 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
||||
Confidence: confidencePercent(it.Confidence),
|
||||
Classified: it.HasClassification,
|
||||
When: formatWhen(it.DateSent, it.ReceivedAt),
|
||||
Restorable: folder != store.FolderInbox,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,6 +141,20 @@ func groupMessages(items []store.MessageListItem, categories []store.Category) [
|
||||
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 {
|
||||
@@ -137,14 +166,28 @@ func confidencePercent(v float64) int {
|
||||
return int(v*100 + 0.5)
|
||||
}
|
||||
|
||||
func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string) {
|
||||
data, err := s.loadInbox(r.Context())
|
||||
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)
|
||||
@@ -153,7 +196,7 @@ func (s *Server) renderInbox(w http.ResponseWriter, r *http.Request, note string
|
||||
|
||||
// handleInboxPartial is the HTMX fragment for the centre list.
|
||||
func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderInbox(w, r, "")
|
||||
s.renderInbox(w, r, "", "")
|
||||
}
|
||||
|
||||
// handleFetchMail runs one IMAP scan, then returns the refreshed list. It is a
|
||||
@@ -170,14 +213,14 @@ func (s *Server) handleFetchMail(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
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.")
|
||||
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.")
|
||||
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())
|
||||
s.renderInbox(w, r, "Mail scan failed: "+err.Error(), "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,12 +262,12 @@ func (s *Server) runClassifier(w http.ResponseWriter, r *http.Request, all bool)
|
||||
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))
|
||||
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.")
|
||||
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())
|
||||
s.renderInbox(w, r, "Classification failed: "+err.Error(), "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,3 +301,18 @@ func formatWhen(sent, received time.Time) string {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user