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:
2026-10-11 21:44:52 +11:00
parent 735934dab2
commit 8c0416750e
18 changed files with 2023 additions and 71 deletions

View File

@@ -215,6 +215,9 @@ type navItem struct {
Name string
Color string
Count int
// Param is the /partials/inbox query fragment for this item, for example
// "category=Action" or "folder=delete" (Phase 4 folder views).
Param string
}
type dashboardData struct {
@@ -250,7 +253,7 @@ func categoryCounts(items []store.MessageListItem, categories []store.Category)
return counts
}
// handleDashboard renders the inbox shell with real category counts.
// handleDashboard renders the inbox shell with real category and folder counts.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r)
@@ -266,15 +269,26 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.logger.Error("load dashboard categories", "error", err)
}
counts := categoryCounts(items, categories)
folderCounts, err := s.store.CountByFolder(r.Context())
if err != nil {
s.logger.Error("load dashboard folder counts", "error", err)
folderCounts = nil
}
catNav := make([]navItem, 0, len(categories)+1)
for _, c := range categories {
catNav = append(catNav, navItem{Name: c.Name, Color: "var(--primary-soft, var(--surface-2))", Count: counts[c.Name]})
catNav = append(catNav, navItem{
Name: c.Name,
Color: "var(--primary-soft, var(--surface-2))",
Count: counts[c.Name],
Param: "category=" + url.QueryEscape(c.Name),
})
}
catNav = append(catNav, navItem{
Name: unclassifiedCategory,
Color: "var(--ink-faint)",
Count: counts[unclassifiedCategory],
Param: "category=" + url.QueryEscape(unclassifiedCategory),
})
data := dashboardData{
@@ -285,9 +299,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
Total: len(items),
Categories: catNav,
Folders: []navItem{
{Name: "Replied-to", Color: "var(--sticker-green)"},
{Name: "Delete", Color: "var(--sticker-red)"},
{Name: "Saved", Color: "var(--sticker-teal)"},
{Name: "Replied-to", Color: "var(--sticker-green)", Count: folderCounts[store.FolderReplied], Param: "folder=" + store.FolderReplied},
{Name: "Delete", Color: "var(--sticker-red)", Count: folderCounts[store.FolderDelete], Param: "folder=" + store.FolderDelete},
{Name: "Saved", Color: "var(--sticker-teal)", Count: folderCounts[store.FolderSaved], Param: "folder=" + store.FolderSaved},
},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")